From b01952b54d92dd9f586d9e1b4e1d28ce42ef2922 Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Thu, 25 Jun 2026 15:34:32 +0000 Subject: [PATCH 01/28] feat(monad_next): implement EIP-7708 ETH transfer/burn logs Emit LOG3/LOG2 from SYSTEM_ADDRESS for value transfers, selfdestruct beneficiary transfers/burns, and finalization burns of deleted accounts. Co-Authored-By: Claude --- src/ethereum/forks/monad_next/fork.py | 29 ++++++- src/ethereum/forks/monad_next/vm/__init__.py | 82 ++++++++++++++++++- .../monad_next/vm/instructions/system.py | 11 +++ .../forks/monad_next/vm/interpreter.py | 6 +- 4 files changed, 122 insertions(+), 6 deletions(-) diff --git a/src/ethereum/forks/monad_next/fork.py b/src/ethereum/forks/monad_next/fork.py index 653f05d030f..ebe71312adf 100644 --- a/src/ethereum/forks/monad_next/fork.py +++ b/src/ethereum/forks/monad_next/fork.py @@ -33,6 +33,7 @@ ) from ethereum.state import EMPTY_CODE_HASH, Address from ethereum.state_paged import State, apply_changes_to_state +from ethereum.utils.byte import left_pad_zero_bytes from . import vm from .blocks import Block, Header, Log, Receipt, Withdrawal, encode_receipt @@ -973,15 +974,32 @@ def process_transaction( # transfer miner fees create_ether(tx_state, block_env.coinbase, U256(transaction_fee)) - for address in tx_output.accounts_to_delete: - destroy_account(tx_state, address) + # EIP-7708: Emit burn logs for balances held by accounts marked for + # deletion AFTER miner fee transfer. + finalization_logs: List[Log] = [] + for address in sorted(tx_output.accounts_to_delete): + balance = get_account(tx_state, address).balance + if balance > U256(0): + padded_address = left_pad_zero_bytes(address, 32) + finalization_logs.append( + Log( + address=vm.SYSTEM_ADDRESS, + topics=( + vm.BURN_TOPIC, + Hash32(padded_address), + ), + data=balance.to_be_bytes32(), + ) + ) + + all_logs = tx_output.logs + tuple(finalization_logs) # block_output.block_gas_used += tx_gas_used_after_refund block_output.block_gas_used += tx.gas block_output.blob_gas_used += tx_blob_gas_used receipt = make_receipt( - tx, tx_output.error, block_output.block_gas_used, tx_output.logs + tx, tx_output.error, block_output.block_gas_used, all_logs ) receipt_key = rlp.encode(Uint(index)) @@ -993,7 +1011,10 @@ def process_transaction( receipt, ) - block_output.block_logs += tx_output.logs + block_output.block_logs += all_logs + + for address in tx_output.accounts_to_delete: + destroy_account(tx_state, address) incorporate_tx_into_block(tx_state) diff --git a/src/ethereum/forks/monad_next/vm/__init__.py b/src/ethereum/forks/monad_next/vm/__init__.py index 15c53bc75b6..9ebf92dd9f5 100644 --- a/src/ethereum/forks/monad_next/vm/__init__.py +++ b/src/ethereum/forks/monad_next/vm/__init__.py @@ -18,10 +18,11 @@ from ethereum_types.bytes import Bytes, Bytes0, Bytes32 from ethereum_types.numeric import U64, U256, Uint -from ethereum.crypto.hash import Hash32 +from ethereum.crypto.hash import Hash32, keccak256 from ethereum.exceptions import EthereumException from ethereum.merkle_patricia_trie import Trie from ethereum.state import Address +from ethereum.utils.byte import left_pad_zero_bytes from ..blocks import Log, Receipt, Withdrawal from ..fork_types import Authorization, VersionedHash @@ -30,6 +31,12 @@ __all__ = ("Environment", "Evm", "Message") +TRANSFER_TOPIC = keccak256(b"Transfer(address,address,uint256)") +BURN_TOPIC = keccak256(b"Burn(address,uint256)") +SYSTEM_ADDRESS = Address( + bytes.fromhex("fffffffffffffffffffffffffffffffffffffffe") +) + @final @dataclass @@ -237,3 +244,76 @@ def incorporate_child_on_error(evm: Evm, child_evm: Evm) -> None: # NOTE: absence of `evm.memory`, in particular of its high watermark # is intended for memory to deallocate on call frame exit. + + +def emit_transfer_log( + evm: Evm, + sender: Address, + recipient: Address, + transfer_amount: U256, +) -> None: + """ + Emit a LOG3 for all ETH transfers satisfying EIP-7708. + + Parameters + ---------- + evm : + The state of the ethereum virtual machine + sender : + The account address sending the transfer + recipient : + The account address receiving the transfer + transfer_amount : + The amount of ETH transacted + + """ + if transfer_amount == 0: + return + + padded_sender = left_pad_zero_bytes(sender, 32) + padded_recipient = left_pad_zero_bytes(recipient, 32) + log_entry = Log( + address=SYSTEM_ADDRESS, + topics=( + TRANSFER_TOPIC, + Hash32(padded_sender), + Hash32(padded_recipient), + ), + data=transfer_amount.to_be_bytes32(), + ) + + evm.logs = evm.logs + (log_entry,) + + +def emit_burn_log( + evm: Evm, + account: Address, + amount: U256, +) -> None: + """ + Emit a LOG2 for ETH burn per EIP-7708. + + Parameters + ---------- + evm : + The state of the ethereum virtual machine + account : + The account address whose ETH is being burned + amount : + The amount of ETH being burned + + """ + if amount == 0: + return + + padded_account = left_pad_zero_bytes(account, 32) + log_entry = Log( + address=SYSTEM_ADDRESS, + topics=( + BURN_TOPIC, + Hash32(padded_account), + ), + data=amount.to_be_bytes32(), + ) + + evm.logs = evm.logs + (log_entry,) diff --git a/src/ethereum/forks/monad_next/vm/instructions/system.py b/src/ethereum/forks/monad_next/vm/instructions/system.py index 7b8634c5d7e..99e31fc1a1b 100644 --- a/src/ethereum/forks/monad_next/vm/instructions/system.py +++ b/src/ethereum/forks/monad_next/vm/instructions/system.py @@ -35,6 +35,8 @@ from .. import ( Evm, Message, + emit_burn_log, + emit_transfer_log, incorporate_child_on_error, incorporate_child_on_success, ) @@ -584,6 +586,15 @@ def selfdestruct(evm: Evm) -> None: originator_balance, ) + # EIP-7708: Emit transfer or burn log for the beneficiary transfer + if ( + originator in evm.message.tx_env.state.created_accounts + and beneficiary == originator + ): + emit_burn_log(evm, originator, originator_balance) + elif beneficiary != originator: + emit_transfer_log(evm, originator, beneficiary, originator_balance) + # register account for deletion only if it was created # in the same transaction if originator in evm.message.tx_env.state.created_accounts: diff --git a/src/ethereum/forks/monad_next/vm/interpreter.py b/src/ethereum/forks/monad_next/vm/interpreter.py index 13089bc8125..f72394c5745 100644 --- a/src/ethereum/forks/monad_next/vm/interpreter.py +++ b/src/ethereum/forks/monad_next/vm/interpreter.py @@ -56,7 +56,7 @@ from ..vm.gas import GasCosts, charge_gas, page_index from ..vm.precompiled_contracts import MONAD_PRECOMPILE_ADDRESSES from ..vm.precompiled_contracts.mapping import PRE_COMPILED_CONTRACTS -from . import Evm, EvmMemory +from . import Evm, EvmMemory, emit_transfer_log from .exceptions import ( AddressCollision, ExceptionalHalt, @@ -412,6 +412,10 @@ def process_message(message: Message) -> Evm: message.current_target, message.value, ) + if message.caller != message.current_target: + emit_transfer_log( + evm, message.caller, message.current_target, message.value + ) try: if evm.message.code_address in PRE_COMPILED_CONTRACTS: From 57b3fbc9c5bfea1858ce213ddfac3dbcfcdd76ec Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Thu, 25 Jun 2026 15:36:37 +0000 Subject: [PATCH 02/28] feat(monad_next): implement EIP-7843 SLOTNUM opcode Add SLOTNUM (0x4b, gas BASE) pushing the block slot number, a slot_number U64 field on the header and block environment, and fork.py plumbing. Co-Authored-By: Claude --- src/ethereum/forks/monad_next/blocks.py | 8 +++++ src/ethereum/forks/monad_next/fork.py | 1 + src/ethereum/forks/monad_next/vm/__init__.py | 1 + src/ethereum/forks/monad_next/vm/gas.py | 1 + .../monad_next/vm/instructions/__init__.py | 2 ++ .../forks/monad_next/vm/instructions/block.py | 33 +++++++++++++++++++ 6 files changed, 46 insertions(+) diff --git a/src/ethereum/forks/monad_next/blocks.py b/src/ethereum/forks/monad_next/blocks.py index f6745f79de3..4d83d39df9e 100644 --- a/src/ethereum/forks/monad_next/blocks.py +++ b/src/ethereum/forks/monad_next/blocks.py @@ -248,6 +248,14 @@ class Header: [SHA2-256]: https://en.wikipedia.org/wiki/SHA-2 """ + slot_number: U64 + """ + The slot number of this block as provided by the consensus layer. + Introduced in [EIP-7843]. + + [EIP-7843]: https://eips.ethereum.org/EIPS/eip-7843 + """ + @final @slotted_freezable diff --git a/src/ethereum/forks/monad_next/fork.py b/src/ethereum/forks/monad_next/fork.py index ebe71312adf..5c4bb5c1fc2 100644 --- a/src/ethereum/forks/monad_next/fork.py +++ b/src/ethereum/forks/monad_next/fork.py @@ -247,6 +247,7 @@ def state_transition(chain: BlockChain, block: Block) -> None: prev_randao=block.header.prev_randao, excess_blob_gas=block.header.excess_blob_gas, parent_beacon_block_root=block.header.parent_beacon_block_root, + slot_number=block.header.slot_number, ) block_output = apply_body( diff --git a/src/ethereum/forks/monad_next/vm/__init__.py b/src/ethereum/forks/monad_next/vm/__init__.py index 9ebf92dd9f5..381db45cad9 100644 --- a/src/ethereum/forks/monad_next/vm/__init__.py +++ b/src/ethereum/forks/monad_next/vm/__init__.py @@ -56,6 +56,7 @@ class BlockEnvironment: prev_randao: Bytes32 excess_blob_gas: U64 parent_beacon_block_root: Hash32 + slot_number: U64 @final diff --git a/src/ethereum/forks/monad_next/vm/gas.py b/src/ethereum/forks/monad_next/vm/gas.py index 870cee592ac..708ec866fb0 100644 --- a/src/ethereum/forks/monad_next/vm/gas.py +++ b/src/ethereum/forks/monad_next/vm/gas.py @@ -189,6 +189,7 @@ class GasCosts: OPCODE_CHAINID: Final[Uint] = BASE OPCODE_BASEFEE: Final[Uint] = BASE OPCODE_BLOBBASEFEE: Final[Uint] = BASE + OPCODE_SLOTNUM: Final[Uint] = BASE OPCODE_BLOBHASH: Final[Uint] = Uint(3) OPCODE_PUSH: Final[Uint] = VERY_LOW OPCODE_PUSH0: Final[Uint] = BASE diff --git a/src/ethereum/forks/monad_next/vm/instructions/__init__.py b/src/ethereum/forks/monad_next/vm/instructions/__init__.py index 0da72c8ea5c..d858b5053f0 100644 --- a/src/ethereum/forks/monad_next/vm/instructions/__init__.py +++ b/src/ethereum/forks/monad_next/vm/instructions/__init__.py @@ -99,6 +99,7 @@ class Ops(enum.Enum): BASEFEE = 0x48 BLOBHASH = 0x49 BLOBBASEFEE = 0x4A + SLOTNUM = 0x4B # Control Flow Ops STOP = 0x00 @@ -251,6 +252,7 @@ class Ops(enum.Enum): Ops.PREVRANDAO: block_instructions.prev_randao, Ops.GASLIMIT: block_instructions.gas_limit, Ops.CHAINID: block_instructions.chain_id, + Ops.SLOTNUM: block_instructions.slot_number, Ops.MLOAD: memory_instructions.mload, Ops.MSTORE: memory_instructions.mstore, Ops.MSTORE8: memory_instructions.mstore8, diff --git a/src/ethereum/forks/monad_next/vm/instructions/block.py b/src/ethereum/forks/monad_next/vm/instructions/block.py index baa589c4395..4f9f9e5d5c3 100644 --- a/src/ethereum/forks/monad_next/vm/instructions/block.py +++ b/src/ethereum/forks/monad_next/vm/instructions/block.py @@ -259,3 +259,36 @@ def chain_id(evm: Evm) -> None: # PROGRAM COUNTER evm.pc += Uint(1) + + +def slot_number(evm: Evm) -> None: + """ + Push the current slot number onto the stack. + + The slot number is provided by the consensus layer and passed to the + execution layer through the engine API. + + Parameters + ---------- + evm : + The current EVM frame. + + Raises + ------ + :py:class:`~ethereum.forks.monad_next.vm.exceptions.StackOverflowError` + If `len(stack)` is equal to `1024`. + :py:class:`~ethereum.forks.monad_next.vm.exceptions.OutOfGasError` + If `evm.gas_left` is less than `2`. + + """ + # STACK + pass + + # GAS + charge_gas(evm, GasCosts.OPCODE_SLOTNUM) + + # OPERATION + push(evm.stack, U256(evm.message.block_env.slot_number)) + + # PROGRAM COUNTER + evm.pc += Uint(1) From 24e4bb825137f9f26c03dbbdd09ec25184ee9763 Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Thu, 25 Jun 2026 15:39:01 +0000 Subject: [PATCH 03/28] feat(monad_next): implement EIP-8024 SWAPN, DUPN, EXCHANGE Add DUPN (0xe6), SWAPN (0xe7), EXCHANGE (0xe8) stack instructions with a 1-byte immediate, gas VERY_LOW, plus immediate-aware jumpdest analysis. Co-Authored-By: Claude --- src/ethereum/forks/monad_next/vm/gas.py | 3 + .../monad_next/vm/instructions/__init__.py | 8 ++ .../forks/monad_next/vm/instructions/stack.py | 107 +++++++++++++++++- src/ethereum/forks/monad_next/vm/runtime.py | 26 +++++ src/ethereum/forks/monad_next/vm/stack.py | 79 ++++++++++++- 5 files changed, 219 insertions(+), 4 deletions(-) diff --git a/src/ethereum/forks/monad_next/vm/gas.py b/src/ethereum/forks/monad_next/vm/gas.py index 708ec866fb0..461f0f79a27 100644 --- a/src/ethereum/forks/monad_next/vm/gas.py +++ b/src/ethereum/forks/monad_next/vm/gas.py @@ -195,6 +195,9 @@ class GasCosts: OPCODE_PUSH0: Final[Uint] = BASE OPCODE_DUP: Final[Uint] = VERY_LOW OPCODE_SWAP: Final[Uint] = VERY_LOW + OPCODE_DUPN: Final[Uint] = VERY_LOW + OPCODE_SWAPN: Final[Uint] = VERY_LOW + OPCODE_EXCHANGE: Final[Uint] = VERY_LOW # Dynamic Opcodes OPCODE_RETURNDATACOPY_BASE: Final[Uint] = VERY_LOW diff --git a/src/ethereum/forks/monad_next/vm/instructions/__init__.py b/src/ethereum/forks/monad_next/vm/instructions/__init__.py index d858b5053f0..06295ec86f1 100644 --- a/src/ethereum/forks/monad_next/vm/instructions/__init__.py +++ b/src/ethereum/forks/monad_next/vm/instructions/__init__.py @@ -189,6 +189,11 @@ class Ops(enum.Enum): SWAP15 = 0x9E SWAP16 = 0x9F + # EIP-8024: Stack access instructions + DUPN = 0xE6 + SWAPN = 0xE7 + EXCHANGE = 0xE8 + # Memory Operations MLOAD = 0x51 MSTORE = 0x52 @@ -352,6 +357,9 @@ class Ops(enum.Enum): Ops.SWAP14: stack_instructions.swap14, Ops.SWAP15: stack_instructions.swap15, Ops.SWAP16: stack_instructions.swap16, + Ops.DUPN: stack_instructions.dupn, + Ops.SWAPN: stack_instructions.swapn, + Ops.EXCHANGE: stack_instructions.exchange, Ops.LOG0: log_instructions.log0, Ops.LOG1: log_instructions.log1, Ops.LOG2: log_instructions.log2, diff --git a/src/ethereum/forks/monad_next/vm/instructions/stack.py b/src/ethereum/forks/monad_next/vm/instructions/stack.py index ce94af6ce8e..0e72bd01f31 100644 --- a/src/ethereum/forks/monad_next/vm/instructions/stack.py +++ b/src/ethereum/forks/monad_next/vm/instructions/stack.py @@ -14,7 +14,7 @@ from functools import partial from typing import Callable -from ethereum_types.numeric import U256, Uint +from ethereum_types.numeric import U8, U256, Uint from .. import Evm, stack from ..exceptions import StackUnderflowError @@ -23,6 +23,7 @@ charge_gas, ) from ..memory import buffer_read +from ..stack import decode_pair, decode_single def pop(evm: Evm) -> None: @@ -210,3 +211,107 @@ def swap_n(evm: Evm, item_number: int) -> None: swap14: Callable[[Evm], None] = partial(swap_n, item_number=14) swap15: Callable[[Evm], None] = partial(swap_n, item_number=15) swap16: Callable[[Evm], None] = partial(swap_n, item_number=16) + + +def dupn(evm: Evm) -> None: + """ + Duplicate the Nth stack item (from top of the stack) to the top of stack. + The item number is read from the immediate byte following the opcode and + decoded using the EIP-8024 index shifting rules. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + pass + + # GAS + charge_gas(evm, GasCosts.OPCODE_DUPN) + + # OPERATION + immediate_data = U8( + buffer_read(evm.code, U256(evm.pc + Uint(1)), U256(1))[0] + ) + item_number = decode_single(immediate_data) + if int(item_number) > len(evm.stack): + raise StackUnderflowError + data_to_duplicate = evm.stack[-item_number] + stack.push(evm.stack, data_to_duplicate) + + # PROGRAM COUNTER + evm.pc += Uint(2) + + +def swapn(evm: Evm) -> None: + """ + Swap the top stack item with the Nth stack item. + The value N is read from the immediate byte following the opcode and + decoded using the EIP-8024 index shifting rules. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + pass + + # GAS + charge_gas(evm, GasCosts.OPCODE_SWAPN) + + # OPERATION + immediate_data = U8( + buffer_read(evm.code, U256(evm.pc + Uint(1)), U256(1))[0] + ) + item_number = decode_single(immediate_data) + # SWAPN with decoded value n swaps top (position 1) with position (n+1) + if int(item_number) + 1 > len(evm.stack): + raise StackUnderflowError + # stack[-1] is top (position 1), stack[-(item_number+1)] is position (n+1) + evm.stack[-1], evm.stack[-(item_number + U8(1))] = ( + evm.stack[-(item_number + U8(1))], + evm.stack[-1], + ) + + # PROGRAM COUNTER + evm.pc += Uint(2) + + +def exchange(evm: Evm) -> None: + """ + Exchange the Nth stack item with the Mth stack item. + The values N and M are decoded from the immediate byte using the + EIP-8024 index shifting rules. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + pass + + # GAS + charge_gas(evm, GasCosts.OPCODE_EXCHANGE) + + # OPERATION + immediate_data = U8( + buffer_read(evm.code, U256(evm.pc + Uint(1)), U256(1))[0] + ) + n, m = decode_pair(immediate_data) + # EXCHANGE swaps position (n+1) with position (m+1) + depth = max(n, m) + U8(1) + if int(depth) > len(evm.stack): + raise StackUnderflowError + evm.stack[-(n + U8(1))], evm.stack[-(m + U8(1))] = ( + evm.stack[-(m + U8(1))], + evm.stack[-(n + U8(1))], + ) + + # PROGRAM COUNTER + evm.pc += Uint(2) diff --git a/src/ethereum/forks/monad_next/vm/runtime.py b/src/ethereum/forks/monad_next/vm/runtime.py index 0aa5ddd5e20..60fd42b52c9 100644 --- a/src/ethereum/forks/monad_next/vm/runtime.py +++ b/src/ethereum/forks/monad_next/vm/runtime.py @@ -28,6 +28,8 @@ def get_valid_jump_destinations(code: Bytes) -> Set[Uint]: * The jump destination should have the `JUMPDEST` opcode (0x5B). * The jump destination shouldn't be part of the data corresponding to `PUSH-N` opcodes. + * The jump destination shouldn't be part of the immediate byte + corresponding to `DUPN`, `SWAPN`, or `EXCHANGE` opcodes (EIP-8024). Note - Jump destinations are 0-indexed. @@ -63,6 +65,30 @@ def get_valid_jump_destinations(code: Bytes) -> Set[Uint]: # opcodes. push_data_size = current_opcode.value - Ops.PUSH1.value + 1 pc += Uint(push_data_size) + elif current_opcode in (Ops.DUPN, Ops.SWAPN): + # EIP-8024: DUPN/SWAPN invalid immediate range is + # 90 < x < 128, i.e. 0x5B (91) to 0x7F (127). + # Invalid immediates are not skipped so the byte + # remains at an instruction boundary. + if ( + pc + Uint(1) < ulen(code) + and 0x5B <= code[pc + Uint(1)] <= 0x7F + ): + pass + else: + pc += Uint(1) + elif current_opcode == Ops.EXCHANGE: + # EIP-8024: EXCHANGE invalid immediate range is + # 81 < x < 128, i.e. 0x52 (82) to 0x7F (127). + # Invalid immediates are not skipped so the byte + # remains at an instruction boundary. + if ( + pc + Uint(1) < ulen(code) + and 0x52 <= code[pc + Uint(1)] <= 0x7F + ): + pass + else: + pc += Uint(1) pc += Uint(1) diff --git a/src/ethereum/forks/monad_next/vm/stack.py b/src/ethereum/forks/monad_next/vm/stack.py index a87b0a47079..98ba815cb73 100644 --- a/src/ethereum/forks/monad_next/vm/stack.py +++ b/src/ethereum/forks/monad_next/vm/stack.py @@ -11,11 +11,84 @@ Implementation of the stack operators for the EVM. """ -from typing import List +from typing import List, Tuple -from ethereum_types.numeric import U256 +from ethereum_types.numeric import U8, U256 -from .exceptions import StackOverflowError, StackUnderflowError +from .exceptions import ( + InvalidParameter, + StackOverflowError, + StackUnderflowError, +) + + +def decode_single(x: U8) -> U8: + """ + Decode the immediate byte for DUPN/SWAPN to get the stack index. + + Return n with 17 <= n <= 235. + + Parameters + ---------- + x : int + The immediate byte value (0-90 or 128-255). + + Returns + ------- + int + The stack index n, where 17 <= n <= 235. + + Raises + ------ + InvalidParameter + If x is in the forbidden range (90 < x < 128 or x > 255). + + """ + if not (U8(0) <= x <= U8(90) or U8(128) <= x <= U8(255)): + raise InvalidParameter( + f"DUPN/SWAPN immediate byte {x} is out of range. " + "Valid range: 0 <= x <= 90 or 128 <= x <= 255" + ) + + return U8((int(x) + 145) % 256) + + +def decode_pair(x: U8) -> Tuple[U8, U8]: + """ + Decode the immediate byte for EXCHANGE to get two stack indices. + + Return (n, m) with 1 <= n <= 14 and n < m <= 30 - n. + + Parameters + ---------- + x : int + The immediate byte value (0-81 or 128-255). + + Returns + ------- + Tuple[int, int] + The two stack indices (n, m), where + 1 <= n <= 14 and n < m <= 30 - n. + + Raises + ------ + InvalidParameter + If x is in the forbidden range (81 < x < 128 or x > 255). + + """ + if not (U8(0) <= x <= U8(81) or U8(128) <= x <= U8(255)): + raise InvalidParameter( + f"EXCHANGE immediate byte {x} is in the forbidden " + "range 82 <= x <= 127\n" + "Valid range: 0 <= x <= 81 or 128 <= x <= 255" + ) + + k = U8(int(x) ^ 143) + q, r = divmod(k, U8(16)) + if q < r: + return q + U8(1), r + U8(1) + else: + return r + U8(1), U8(29) - q def pop(stack: List[U256]) -> U256: From fe8abdac47720ed3c67ea8bec134e23b420e4dde Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:12:36 +0000 Subject: [PATCH 04/28] feat(forks): make MONAD_NEXT an Amsterdam fork inheriting 3 EIPs Inherit MONAD_NEXT from Amsterdam, taking only EIP-7708/7843/8024 changes (opcodes, slot number header) and pinning EIP-7928/7954/7976/7981 members back to MONAD_TEN. Relocated after Amsterdam to resolve the base reference. Co-Authored-By: Claude --- .../execution_testing/forks/forks/forks.py | 72 +++++++++++++++++-- 1 file changed, 66 insertions(+), 6 deletions(-) diff --git a/packages/testing/src/execution_testing/forks/forks/forks.py b/packages/testing/src/execution_testing/forks/forks/forks.py index ecf3b02f3ae..7fab30a6306 100644 --- a/packages/testing/src/execution_testing/forks/forks/forks.py +++ b/packages/testing/src/execution_testing/forks/forks/forks.py @@ -1805,12 +1805,6 @@ def _calculate_sstore_gas_mip8( return gas_cost -class MONAD_NEXT(MONAD_TEN): # noqa: N801 - """MONAD_NEXT fork, a placeholder identical to MONAD_TEN.""" - - pass - - class BPO1( Osaka, bpo_fork=True, @@ -1904,3 +1898,69 @@ def engine_payload_attribute_target_gas_limit(cls) -> bool: limit. """ return True + + +class MONAD_NEXT(MONAD_TEN, Amsterdam): # noqa: N801 + """ + MONAD_NEXT fork. + + Amsterdam-based successor to MONAD_TEN. Only the EIP-7708, EIP-7843 + and EIP-8024 changes are inherited from Amsterdam; every other + Amsterdam change is pinned back to the MONAD_TEN parent. + """ + + @classmethod + def valid_opcodes(cls) -> List[Opcodes]: + """ + Inherit the Amsterdam opcode set: SLOTNUM (EIP-7843) and SWAPN, + DUPN, EXCHANGE (EIP-8024) on top of the MONAD_TEN opcodes. + """ + return Amsterdam.valid_opcodes() + + @classmethod + def max_code_size(cls) -> int: + """Return spec from explicit parent (skip EIP-7954).""" + return MONAD_TEN.max_code_size() + + @classmethod + def calldata_gas_calculator(cls) -> CalldataGasCalculator: + """Return spec from explicit parent (skip EIP-7976).""" + return MONAD_TEN.calldata_gas_calculator() + + @classmethod + def transaction_data_floor_cost_calculator( + cls, + ) -> TransactionDataFloorCostCalculator: + """Return spec from explicit parent (skip EIP-7981).""" + return MONAD_TEN.transaction_data_floor_cost_calculator() + + @classmethod + def transaction_intrinsic_cost_calculator( + cls, + ) -> TransactionIntrinsicCostCalculator: + """Return spec from explicit parent (skip EIP-7981).""" + return MONAD_TEN.transaction_intrinsic_cost_calculator() + + @classmethod + def header_bal_hash_required(cls) -> bool: + """Return spec from explicit parent (skip EIP-7928).""" + return MONAD_TEN.header_bal_hash_required() + + @classmethod + def empty_block_bal_item_count(cls) -> int: + """Return spec from explicit parent (skip EIP-7928).""" + return MONAD_TEN.empty_block_bal_item_count() + + @classmethod + def engine_execution_payload_block_access_list(cls) -> bool: + """Return spec from explicit parent (skip EIP-7928).""" + return MONAD_TEN.engine_execution_payload_block_access_list() + + +# MONAD_NEXT adopts EIP-7708, EIP-7843 and EIP-8024 from Amsterdam through the +# MRO rather than by inheriting their mixin classes: inheriting them would +# register MONAD_NEXT as a spurious `enabling_fork` (breaking +# `valid_at_transition_to`) and pull in EIP-7843's engine version bumps. +# Record the adopted EIP numbers on `_enabled_eips` directly so that +# `is_eip_enabled()` reports them without those side effects. +MONAD_NEXT._enabled_eips = MONAD_TEN._enabled_eips | {7708, 7843, 8024} From d4119a45f365064c8ff8c62e8fce9e4cc41bc3d1 Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Fri, 26 Jun 2026 15:02:25 +0000 Subject: [PATCH 05/28] feat(ci): add monad_amsterdam fixture release config Single-fork (--fork MONAD_NEXT) feature covering only the adopted Amsterdam EIP dirs (7708/7843/8024); released via tag tests-monad_amsterdam@v0.1.0. Co-Authored-By: Claude --- .github/configs/feature.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/configs/feature.yaml b/.github/configs/feature.yaml index 7fca18fd9ef..8865d8dd458 100644 --- a/.github/configs/feature.yaml +++ b/.github/configs/feature.yaml @@ -11,3 +11,7 @@ monad_runloop: evm-type: eels # Like `monad`, but `--monad-runloop` and eestnet chain id `30143` fill-params: -m blockchain_test --from=MONAD_EIGHT --until=MONAD_TEN --chain-id=30143 --monad-runloop -k "not invalid_header" + +monad_amsterdam: + evm-type: eels + fill-params: -m blockchain_test --fork=MONAD_NEXT --chain-id=143 -k "not invalid_header" tests/amsterdam/eip7708_eth_transfer_logs tests/amsterdam/eip7843_slotnum tests/amsterdam/eip8024_dupn_swapn_exchange From 1466f6aa9f641a207313f23d30babafe3e260cb8 Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:46:45 +0000 Subject: [PATCH 06/28] feat(ci): fill whole test suite for monad_amsterdam release Disable collection of non-adopted Amsterdam EIP tests on Monad forks. Co-Authored-By: Claude --- .github/configs/feature.yaml | 2 +- .../conftest.py | 10 ++++++++++ .../eip7928_block_level_access_lists/conftest.py | 10 ++++++++++ .../eip7954_increase_max_contract_size/conftest.py | 7 +++++++ .../eip7976_increase_calldata_floor_cost/conftest.py | 7 +++++++ .../eip7981_increase_access_list_cost/conftest.py | 7 +++++++ .../eip7928_block_level_access_lists/conftest.py | 10 ++++++++++ .../eip7928_block_level_access_lists/conftest.py | 10 ++++++++++ 8 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 tests/amsterdam/eip7778_block_gas_accounting_without_refunds/conftest.py create mode 100644 tests/amsterdam/eip7928_block_level_access_lists/conftest.py create mode 100644 tests/benchmark/compute/eip7928_block_level_access_lists/conftest.py create mode 100644 tests/benchmark/stateful/eip7928_block_level_access_lists/conftest.py diff --git a/.github/configs/feature.yaml b/.github/configs/feature.yaml index 8865d8dd458..38dce031d7d 100644 --- a/.github/configs/feature.yaml +++ b/.github/configs/feature.yaml @@ -14,4 +14,4 @@ monad_runloop: monad_amsterdam: evm-type: eels - fill-params: -m blockchain_test --fork=MONAD_NEXT --chain-id=143 -k "not invalid_header" tests/amsterdam/eip7708_eth_transfer_logs tests/amsterdam/eip7843_slotnum tests/amsterdam/eip8024_dupn_swapn_exchange + fill-params: -m blockchain_test --fork=MONAD_NEXT --chain-id=143 -k "not invalid_header" diff --git a/tests/amsterdam/eip7778_block_gas_accounting_without_refunds/conftest.py b/tests/amsterdam/eip7778_block_gas_accounting_without_refunds/conftest.py new file mode 100644 index 00000000000..5cbdebdc9ef --- /dev/null +++ b/tests/amsterdam/eip7778_block_gas_accounting_without_refunds/conftest.py @@ -0,0 +1,10 @@ +"""Pytest (plugin) definitions local to EIP-7778 tests.""" + +import pytest + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Mark all tests in this subdir as not valid for Monad forks.""" + metafunc.definition.add_marker( + pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) + ) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/conftest.py b/tests/amsterdam/eip7928_block_level_access_lists/conftest.py new file mode 100644 index 00000000000..3bedcfe2651 --- /dev/null +++ b/tests/amsterdam/eip7928_block_level_access_lists/conftest.py @@ -0,0 +1,10 @@ +"""Pytest (plugin) definitions local to EIP-7928 tests.""" + +import pytest + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Mark all tests in this subdir as not valid for Monad forks.""" + metafunc.definition.add_marker( + pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) + ) diff --git a/tests/amsterdam/eip7954_increase_max_contract_size/conftest.py b/tests/amsterdam/eip7954_increase_max_contract_size/conftest.py index 78cb66ed14d..b4d596bdc52 100644 --- a/tests/amsterdam/eip7954_increase_max_contract_size/conftest.py +++ b/tests/amsterdam/eip7954_increase_max_contract_size/conftest.py @@ -4,6 +4,13 @@ from execution_testing import Address, Alloc, Bytecode, Fork, Op +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Mark all tests in this subdir as not valid for Monad forks.""" + metafunc.definition.add_marker( + pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) + ) + + @pytest.fixture def max_code_size_contract( pre: Alloc, diff --git a/tests/amsterdam/eip7976_increase_calldata_floor_cost/conftest.py b/tests/amsterdam/eip7976_increase_calldata_floor_cost/conftest.py index f92a0e003a0..db99cddc93c 100644 --- a/tests/amsterdam/eip7976_increase_calldata_floor_cost/conftest.py +++ b/tests/amsterdam/eip7976_increase_calldata_floor_cost/conftest.py @@ -23,6 +23,13 @@ from .helpers import DataTestType, find_floor_cost_threshold +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Mark all tests in this subdir as not valid for Monad forks.""" + metafunc.definition.add_marker( + pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) + ) + + @pytest.fixture def to( request: pytest.FixtureRequest, diff --git a/tests/amsterdam/eip7981_increase_access_list_cost/conftest.py b/tests/amsterdam/eip7981_increase_access_list_cost/conftest.py index 104a73e464a..c02d771f5d2 100644 --- a/tests/amsterdam/eip7981_increase_access_list_cost/conftest.py +++ b/tests/amsterdam/eip7981_increase_access_list_cost/conftest.py @@ -22,6 +22,13 @@ from ...cancun.eip4844_blobs.spec import Spec as EIP_4844_Spec +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Mark all tests in this subdir as not valid for Monad forks.""" + metafunc.definition.add_marker( + pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) + ) + + @pytest.fixture def to( request: pytest.FixtureRequest, diff --git a/tests/benchmark/compute/eip7928_block_level_access_lists/conftest.py b/tests/benchmark/compute/eip7928_block_level_access_lists/conftest.py new file mode 100644 index 00000000000..9dc5216d7ea --- /dev/null +++ b/tests/benchmark/compute/eip7928_block_level_access_lists/conftest.py @@ -0,0 +1,10 @@ +"""Pytest (plugin) definitions local to EIP-7928 benchmark tests.""" + +import pytest + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Mark all tests in this subdir as not valid for Monad forks.""" + metafunc.definition.add_marker( + pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) + ) diff --git a/tests/benchmark/stateful/eip7928_block_level_access_lists/conftest.py b/tests/benchmark/stateful/eip7928_block_level_access_lists/conftest.py new file mode 100644 index 00000000000..9dc5216d7ea --- /dev/null +++ b/tests/benchmark/stateful/eip7928_block_level_access_lists/conftest.py @@ -0,0 +1,10 @@ +"""Pytest (plugin) definitions local to EIP-7928 benchmark tests.""" + +import pytest + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Mark all tests in this subdir as not valid for Monad forks.""" + metafunc.definition.add_marker( + pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) + ) From a3d7fd8889e2a50acf6561a9e152b088fc08121d Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:20:29 +0000 Subject: [PATCH 07/28] feat(forks): carry zero EIP-7928 BAL hash slot in MONAD_NEXT headers Header field sits between requests_hash and slot_number and must be zero. Co-Authored-By: Claude --- .../evm_tools/t8n/block_environment.py | 2 +- .../execution_testing/fixtures/blockchain.py | 8 +++++- .../src/execution_testing/forks/base_fork.py | 12 ++++++++ .../forks/forks/eips/amsterdam/eip_7928.py | 7 +++++ .../execution_testing/forks/forks/forks.py | 13 +++++++-- .../src/execution_testing/specs/blockchain.py | 28 +++++++++++-------- src/ethereum/forks/monad_next/__init__.py | 8 ++++-- src/ethereum/forks/monad_next/blocks.py | 10 +++++++ src/ethereum/forks/monad_next/fork.py | 2 ++ .../loaders/fork_loader.py | 14 ++++++++++ 10 files changed, 86 insertions(+), 18 deletions(-) diff --git a/packages/testing/src/execution_testing/evm_tools/t8n/block_environment.py b/packages/testing/src/execution_testing/evm_tools/t8n/block_environment.py index 3a602b9c6c4..ce61b3914f5 100644 --- a/packages/testing/src/execution_testing/evm_tools/t8n/block_environment.py +++ b/packages/testing/src/execution_testing/evm_tools/t8n/block_environment.py @@ -160,7 +160,7 @@ def _resolve_excess_blob_gas( } if fork.has_compute_requests_hash: arguments["requests_hash"] = Hash32(b"\0" * 32) - if fork.has_hash_block_access_list: + if fork.has_block_access_list_hash_header: arguments["block_access_list_hash"] = Hash32(b"\0" * 32) if fork.has_slot_number: arguments["slot_number"] = U64(0) diff --git a/packages/testing/src/execution_testing/fixtures/blockchain.py b/packages/testing/src/execution_testing/fixtures/blockchain.py index a7331378786..7d4269bfd95 100644 --- a/packages/testing/src/execution_testing/fixtures/blockchain.py +++ b/packages/testing/src/execution_testing/fixtures/blockchain.py @@ -377,7 +377,13 @@ def genesis(cls, fork: Fork, env: Environment, state_root: Hash) -> Self: if fork.header_requests_required(): extras["requests_hash"] = Requests() if fork.header_bal_hash_required(): - extras["block_access_list_hash"] = BlockAccessList().rlp_hash + # A fork can require the header field without building block + # access lists (e.g. Monad); the field is then fixed at zero. + extras["block_access_list_hash"] = ( + BlockAccessList().rlp_hash + if fork.supports_block_access_lists() + else Hash(0) + ) if fork.header_slot_number_required(): extras["slot_number"] = ( int(env.slot_number) if env.slot_number is not None else 0 diff --git a/packages/testing/src/execution_testing/forks/base_fork.py b/packages/testing/src/execution_testing/forks/base_fork.py index f842878c42f..aae401d8b08 100644 --- a/packages/testing/src/execution_testing/forks/base_fork.py +++ b/packages/testing/src/execution_testing/forks/base_fork.py @@ -563,6 +563,18 @@ def header_bal_hash_required(cls) -> bool: """Return true if the header must contain block access list hash.""" pass + @classmethod + @abstractmethod + def supports_block_access_lists(cls) -> bool: + """ + Return true if the fork builds block access lists (EIP-7928). + + A fork can require the block access list hash header field without + building block access lists (e.g. Monad); the field is then fixed + at zero. + """ + pass + @classmethod @abstractmethod def empty_block_bal_item_count(cls) -> int: diff --git a/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_7928.py b/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_7928.py index 1eacd844b21..d7c787eed92 100644 --- a/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_7928.py +++ b/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_7928.py @@ -29,6 +29,13 @@ def header_bal_hash_required(cls) -> bool: """ return True + @classmethod + def supports_block_access_lists(cls) -> bool: + """ + From EIP-7928, blocks build block access lists. + """ + return True + @classmethod def gas_costs(cls) -> GasCosts: """ diff --git a/packages/testing/src/execution_testing/forks/forks/forks.py b/packages/testing/src/execution_testing/forks/forks/forks.py index 7fab30a6306..1151b29531c 100644 --- a/packages/testing/src/execution_testing/forks/forks/forks.py +++ b/packages/testing/src/execution_testing/forks/forks/forks.py @@ -954,6 +954,11 @@ def header_bal_hash_required(cls) -> bool: """At genesis, header must not contain block access list hash.""" return False + @classmethod + def supports_block_access_lists(cls) -> bool: + """At genesis, no block access lists are built.""" + return False + @classmethod def empty_block_bal_item_count(cls) -> int: """Pre-Amsterdam forks have no block access list.""" @@ -1906,7 +1911,9 @@ class MONAD_NEXT(MONAD_TEN, Amsterdam): # noqa: N801 Amsterdam-based successor to MONAD_TEN. Only the EIP-7708, EIP-7843 and EIP-8024 changes are inherited from Amsterdam; every other - Amsterdam change is pinned back to the MONAD_TEN parent. + Amsterdam change is pinned back to the MONAD_TEN parent. The + EIP-7928 block access list hash header field is carried, but no + block access lists are built, so the field is always zero. """ @classmethod @@ -1942,9 +1949,9 @@ def transaction_intrinsic_cost_calculator( return MONAD_TEN.transaction_intrinsic_cost_calculator() @classmethod - def header_bal_hash_required(cls) -> bool: + def supports_block_access_lists(cls) -> bool: """Return spec from explicit parent (skip EIP-7928).""" - return MONAD_TEN.header_bal_hash_required() + return MONAD_TEN.supports_block_access_lists() @classmethod def empty_block_bal_item_count(cls) -> int: diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index c7f5398ae2e..8b996720f20 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -986,17 +986,23 @@ def generate_block_data( int(env.slot_number) if env.slot_number is not None else 0 ) + header_fields = transition_tool_output.result.model_dump( + exclude_none=True, + exclude={"blob_gas_used", "transactions_trie"}, + ) | env.model_dump( + exclude_none=True, + exclude={"blob_gas_used", "slot_number"}, + ) + if fork.header_bal_hash_required() and ( + not fork.supports_block_access_lists() + ): + # Fork requires the block access list hash header field but + # doesn't build block access lists (e.g. Monad): fix value at + # zero. + header_fields.setdefault("block_access_list_hash", Hash(0)) + header = FixtureHeader( - **( - transition_tool_output.result.model_dump( - exclude_none=True, - exclude={"blob_gas_used", "transactions_trie"}, - ) - | env.model_dump( - exclude_none=True, - exclude={"blob_gas_used", "slot_number"}, - ) - ), + **header_fields, blob_gas_used=blob_gas_used, transactions_trie=Transaction.list_root(txs), extra_data=( @@ -1059,7 +1065,7 @@ def generate_block_data( if t8n_bal_rlp is not None: t8n_bal = BlockAccessList.from_rlp(t8n_bal_rlp) - if fork.header_bal_hash_required(): + if fork.supports_block_access_lists(): assert t8n_bal is not None, ( "Block access list is required for this block but was not " "provided by the transition tool" diff --git a/src/ethereum/forks/monad_next/__init__.py b/src/ethereum/forks/monad_next/__init__.py index b6c71ab2450..2e989370ce9 100644 --- a/src/ethereum/forks/monad_next/__init__.py +++ b/src/ethereum/forks/monad_next/__init__.py @@ -1,6 +1,10 @@ """ -MONAD_NEXT fork is a placeholder for upcoming Monad changes and is -currently identical to MONAD_TEN. +MONAD_NEXT fork is a placeholder for upcoming Monad changes. It builds on +MONAD_TEN, adopting EIP-7708, EIP-7843 and EIP-8024 from Amsterdam +together with the Amsterdam block header layout; the [EIP-7928] block +access list hash header slot is carried but always zero. + +[EIP-7928]: https://eips.ethereum.org/EIPS/eip-7928 """ from ethereum.fork_criteria import ByTimestamp, ForkCriteria diff --git a/src/ethereum/forks/monad_next/blocks.py b/src/ethereum/forks/monad_next/blocks.py index 4d83d39df9e..7a66282a7ab 100644 --- a/src/ethereum/forks/monad_next/blocks.py +++ b/src/ethereum/forks/monad_next/blocks.py @@ -248,6 +248,16 @@ class Header: [SHA2-256]: https://en.wikipedia.org/wiki/SHA-2 """ + block_access_list_hash: Hash32 + """ + Header slot introduced by [EIP-7928] for the hash of the Block Access + List. Monad does not build block access lists, so this field is always + zero. See [`validate_header`][vh]. + + [EIP-7928]: https://eips.ethereum.org/EIPS/eip-7928 + [vh]: ref:ethereum.forks.monad_next.fork.validate_header + """ + slot_number: U64 """ The slot number of this block as provided by the consensus layer. diff --git a/src/ethereum/forks/monad_next/fork.py b/src/ethereum/forks/monad_next/fork.py index 5c4bb5c1fc2..0288944313f 100644 --- a/src/ethereum/forks/monad_next/fork.py +++ b/src/ethereum/forks/monad_next/fork.py @@ -404,6 +404,8 @@ def validate_header(chain: BlockChain, header: Header) -> None: raise InvalidBlock if header.ommers_hash != EMPTY_OMMER_HASH: raise InvalidBlock + if header.block_access_list_hash != Hash32(b"\x00" * 32): + raise InvalidBlock block_parent_hash = keccak256(rlp.encode(parent_header)) if header.parent_hash != block_parent_hash: diff --git a/src/ethereum_spec_tools/loaders/fork_loader.py b/src/ethereum_spec_tools/loaders/fork_loader.py index d9e02de32f9..70467ed3b88 100644 --- a/src/ethereum_spec_tools/loaders/fork_loader.py +++ b/src/ethereum_spec_tools/loaders/fork_loader.py @@ -154,6 +154,20 @@ def has_hash_block_access_list(self) -> bool: return False return hasattr(module, "hash_block_access_list") + @property + def has_block_access_list_hash_header(self) -> bool: + """ + Check if the fork's header has a `block_access_list_hash` field. + + A fork can carry the header field without building block access + lists (e.g. Monad, where the field is always zero). + """ + try: + header = self._module("blocks").Header + return "block_access_list_hash" in header.__dataclass_fields__ + except (ModuleNotFoundError, AttributeError): + return False + @property def BlockAccessIndex(self) -> Any: """BlockAccessIndex type of the fork.""" From 8796fdd7ed6919710592e66c84aac37344e463ce Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:49:52 +0000 Subject: [PATCH 08/28] refactor(forks): adopt Amsterdam EIPs by mixin, order by succession Inheriting Amsterdam routed its gas schedule and unadopted EIPs into MONAD_NEXT through the MRO; `follows` keeps the fork order without the behavior. Co-Authored-By: Claude --- .../pytest_commands/plugins/forks/forks.py | 4 +- .../src/execution_testing/forks/base_fork.py | 20 ++++- .../execution_testing/forks/forks/forks.py | 73 ++++--------------- 3 files changed, 35 insertions(+), 62 deletions(-) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/forks.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/forks.py index 6075481806c..29ef4dc86e3 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/forks.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/forks.py @@ -1265,7 +1265,9 @@ def _process_with_marker_args( "Missing fork argument with 'valid_at_transition_to' marker." ) - if len(forks) > 1: + # A single EIP argument expands to one fork per enabling fork, so + # the limit is on the arguments rather than on the resolved forks. + if len(fork_args) > 1: raise Exception( "Too many forks specified to 'valid_at_transition_to' marker." ) diff --git a/packages/testing/src/execution_testing/forks/base_fork.py b/packages/testing/src/execution_testing/forks/base_fork.py index aae401d8b08..4767d361e35 100644 --- a/packages/testing/src/execution_testing/forks/base_fork.py +++ b/packages/testing/src/execution_testing/forks/base_fork.py @@ -333,12 +333,23 @@ def _maybe_transitioned(fork_cls: "BaseForkMeta") -> "BaseForkMeta": @staticmethod def _is_subclass_of(a: "BaseForkMeta", b: "BaseForkMeta") -> bool: """ - Check if `a` is a subclass of `b`, taking fork transitions into - account. + Check if `a` is a subclass of `b`, taking fork transitions and + declared succession into account. + + A fork can follow another fork it does not inherit from, which + places it after that fork (and after everything that fork comes + after) in the fork order without adopting its behavior. """ a = BaseForkMeta._maybe_transitioned(a) b = BaseForkMeta._maybe_transitioned(b) - return issubclass(a, b) + if issubclass(a, b): + return True + followed = getattr(a, "_follows", None) + while followed is not None: + if issubclass(followed, b): + return True + followed = followed._follows + return False def __gt__(cls, other: "BaseForkMeta") -> bool: """Compare if a fork is newer than some other fork (cls > other).""" @@ -381,6 +392,7 @@ class BaseFork(ForkOpcodeInterface, metaclass=BaseForkMeta): _fork_by_timestamp: ClassVar[bool] = False _blob_constants: ClassVar[Dict[str, int]] = {} _deployed: ClassVar[bool] = True + _follows: ClassVar[Optional[Type["BaseFork"]]] = None _enabled_eips: ClassVar[Set[int]] = set() _enabling_forks: ClassVar[Set[Type["BaseFork"]]] = set() @@ -396,6 +408,7 @@ def __init_subclass__( transition_tool_name: Optional[str] = None, ignore: bool = False, bpo_fork: bool = False, + follows: Optional[Type["BaseFork"]] = None, ruleset_name: Optional[str] = None, fork_by_timestamp: Optional[bool] = None, deployed: Optional[bool] = None, @@ -410,6 +423,7 @@ def __init_subclass__( forks. """ cls._transition_tool_name = transition_tool_name + cls._follows = follows cls._ignore = ignore cls._bpo_fork = bpo_fork cls._ruleset_name = ruleset_name diff --git a/packages/testing/src/execution_testing/forks/forks/forks.py b/packages/testing/src/execution_testing/forks/forks/forks.py index 1151b29531c..e3e4ffe4932 100644 --- a/packages/testing/src/execution_testing/forks/forks/forks.py +++ b/packages/testing/src/execution_testing/forks/forks/forks.py @@ -1905,69 +1905,26 @@ def engine_payload_attribute_target_gas_limit(cls) -> bool: return True -class MONAD_NEXT(MONAD_TEN, Amsterdam): # noqa: N801 +class MONAD_NEXT( # noqa: N801 + eips.EIP7708, + eips.EIP7843, + eips.EIP8024, + MONAD_TEN, + follows=Amsterdam, +): """ MONAD_NEXT fork. - Amsterdam-based successor to MONAD_TEN. Only the EIP-7708, EIP-7843 - and EIP-8024 changes are inherited from Amsterdam; every other - Amsterdam change is pinned back to the MONAD_TEN parent. The - EIP-7928 block access list hash header field is carried, but no - block access lists are built, so the field is always zero. + Amsterdam-based successor to MONAD_TEN, adopting the EIP-7708, + EIP-7843 and EIP-8024 changes. The Amsterdam changes it does not + adopt stay out of the fork by not being inherited at all; the fork + order still places MONAD_NEXT after Amsterdam through `follows`. """ @classmethod - def valid_opcodes(cls) -> List[Opcodes]: + def header_bal_hash_required(cls) -> bool: """ - Inherit the Amsterdam opcode set: SLOTNUM (EIP-7843) and SWAPN, - DUPN, EXCHANGE (EIP-8024) on top of the MONAD_TEN opcodes. + MONAD_NEXT headers carry the block access list hash field, fixed + at zero, without building block access lists (EIP-7928). """ - return Amsterdam.valid_opcodes() - - @classmethod - def max_code_size(cls) -> int: - """Return spec from explicit parent (skip EIP-7954).""" - return MONAD_TEN.max_code_size() - - @classmethod - def calldata_gas_calculator(cls) -> CalldataGasCalculator: - """Return spec from explicit parent (skip EIP-7976).""" - return MONAD_TEN.calldata_gas_calculator() - - @classmethod - def transaction_data_floor_cost_calculator( - cls, - ) -> TransactionDataFloorCostCalculator: - """Return spec from explicit parent (skip EIP-7981).""" - return MONAD_TEN.transaction_data_floor_cost_calculator() - - @classmethod - def transaction_intrinsic_cost_calculator( - cls, - ) -> TransactionIntrinsicCostCalculator: - """Return spec from explicit parent (skip EIP-7981).""" - return MONAD_TEN.transaction_intrinsic_cost_calculator() - - @classmethod - def supports_block_access_lists(cls) -> bool: - """Return spec from explicit parent (skip EIP-7928).""" - return MONAD_TEN.supports_block_access_lists() - - @classmethod - def empty_block_bal_item_count(cls) -> int: - """Return spec from explicit parent (skip EIP-7928).""" - return MONAD_TEN.empty_block_bal_item_count() - - @classmethod - def engine_execution_payload_block_access_list(cls) -> bool: - """Return spec from explicit parent (skip EIP-7928).""" - return MONAD_TEN.engine_execution_payload_block_access_list() - - -# MONAD_NEXT adopts EIP-7708, EIP-7843 and EIP-8024 from Amsterdam through the -# MRO rather than by inheriting their mixin classes: inheriting them would -# register MONAD_NEXT as a spurious `enabling_fork` (breaking -# `valid_at_transition_to`) and pull in EIP-7843's engine version bumps. -# Record the adopted EIP numbers on `_enabled_eips` directly so that -# `is_eip_enabled()` reports them without those side effects. -MONAD_NEXT._enabled_eips = MONAD_TEN._enabled_eips | {7708, 7843, 8024} + return True From efe211ddebff4d5a6c0d5e623cd158a7db7dc9eb Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:54:45 +0000 Subject: [PATCH 09/28] test(amsterdam): keep the unadopted Amsterdam suites off the Monad forks MONAD_NEXT follows Amsterdam, so its `valid_from` suites select the fork; the seven EIPs it does not adopt now exclude Monad like the rest. Co-Authored-By: Claude --- .../eip2780_reduce_intrinsic_tx_gas/conftest.py | 10 ++++++++++ .../conftest.py | 10 ++++++++++ .../conftest.py | 10 ++++++++++ .../eip8038_state_access_gas_cost_increase/conftest.py | 10 ++++++++++ tests/amsterdam/eip8070_sparse_blobpool/conftest.py | 7 +++++++ .../amsterdam/eip8246_selfdestruct_no_burn/conftest.py | 10 ++++++++++ .../eip8282_builder_execution_requests/conftest.py | 9 +++++++++ 7 files changed, 66 insertions(+) create mode 100644 tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/conftest.py create mode 100644 tests/amsterdam/eip7997_deterministic_factory_predeploy/conftest.py create mode 100644 tests/amsterdam/eip8037_state_creation_gas_cost_increase/conftest.py create mode 100644 tests/amsterdam/eip8038_state_access_gas_cost_increase/conftest.py create mode 100644 tests/amsterdam/eip8246_selfdestruct_no_burn/conftest.py diff --git a/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/conftest.py b/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/conftest.py new file mode 100644 index 00000000000..de52075d396 --- /dev/null +++ b/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/conftest.py @@ -0,0 +1,10 @@ +"""Pytest (plugin) definitions local to EIP-2780 tests.""" + +import pytest + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Mark all tests in this subdir as not valid for Monad forks.""" + metafunc.definition.add_marker( + pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) + ) diff --git a/tests/amsterdam/eip7997_deterministic_factory_predeploy/conftest.py b/tests/amsterdam/eip7997_deterministic_factory_predeploy/conftest.py new file mode 100644 index 00000000000..de93abfcfef --- /dev/null +++ b/tests/amsterdam/eip7997_deterministic_factory_predeploy/conftest.py @@ -0,0 +1,10 @@ +"""Pytest (plugin) definitions local to EIP-7997 tests.""" + +import pytest + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Mark all tests in this subdir as not valid for Monad forks.""" + metafunc.definition.add_marker( + pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) + ) diff --git a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/conftest.py b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/conftest.py new file mode 100644 index 00000000000..2d16c275db4 --- /dev/null +++ b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/conftest.py @@ -0,0 +1,10 @@ +"""Pytest (plugin) definitions local to EIP-8037 tests.""" + +import pytest + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Mark all tests in this subdir as not valid for Monad forks.""" + metafunc.definition.add_marker( + pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) + ) diff --git a/tests/amsterdam/eip8038_state_access_gas_cost_increase/conftest.py b/tests/amsterdam/eip8038_state_access_gas_cost_increase/conftest.py new file mode 100644 index 00000000000..cd29eb50dcb --- /dev/null +++ b/tests/amsterdam/eip8038_state_access_gas_cost_increase/conftest.py @@ -0,0 +1,10 @@ +"""Pytest (plugin) definitions local to EIP-8038 tests.""" + +import pytest + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Mark all tests in this subdir as not valid for Monad forks.""" + metafunc.definition.add_marker( + pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) + ) diff --git a/tests/amsterdam/eip8070_sparse_blobpool/conftest.py b/tests/amsterdam/eip8070_sparse_blobpool/conftest.py index cb2a757494c..dc3ee7c3ecd 100644 --- a/tests/amsterdam/eip8070_sparse_blobpool/conftest.py +++ b/tests/amsterdam/eip8070_sparse_blobpool/conftest.py @@ -148,3 +148,10 @@ def txs( ) txs.append(network_wrapped_tx) return txs + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Mark all tests in this subdir as not valid for Monad forks.""" + metafunc.definition.add_marker( + pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) + ) diff --git a/tests/amsterdam/eip8246_selfdestruct_no_burn/conftest.py b/tests/amsterdam/eip8246_selfdestruct_no_burn/conftest.py new file mode 100644 index 00000000000..4fb53dba402 --- /dev/null +++ b/tests/amsterdam/eip8246_selfdestruct_no_burn/conftest.py @@ -0,0 +1,10 @@ +"""Pytest (plugin) definitions local to EIP-8246 tests.""" + +import pytest + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Mark all tests in this subdir as not valid for Monad forks.""" + metafunc.definition.add_marker( + pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) + ) diff --git a/tests/amsterdam/eip8282_builder_execution_requests/conftest.py b/tests/amsterdam/eip8282_builder_execution_requests/conftest.py index e17cee467fc..7a42ba76785 100644 --- a/tests/amsterdam/eip8282_builder_execution_requests/conftest.py +++ b/tests/amsterdam/eip8282_builder_execution_requests/conftest.py @@ -1,8 +1,17 @@ """Fixtures for the EIP-8282 builder execution request tests.""" +import pytest + from ...common.system_contract_request_fixtures import ( blocks, # noqa: F401 included_requests, # noqa: F401 system_contract_interactions_per_block_copy, # noqa: F401 timestamp, # noqa: F401 ) + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Mark all tests in this subdir as not valid for Monad forks.""" + metafunc.definition.add_marker( + pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) + ) From e85abeaadf84f445cb53ced2d68f3b98aaecd140 Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:34:51 +0000 Subject: [PATCH 10/28] feat(monad_next): adopt EIP-8246 remove SELFDESTRUCT balance burn MONAD_NEXT clears selfdestructed accounts in place, ordered after Amsterdam by succession, and releases as the monad_eip8246 feature. Co-Authored-By: Claude --- .github/configs/feature.yaml | 4 +++ .../pytest_commands/plugins/forks/forks.py | 4 ++- .../src/execution_testing/forks/base_fork.py | 20 ++++++++++-- .../execution_testing/forks/forks/forks.py | 23 ++++++++++---- src/ethereum/forks/monad_next/fork.py | 4 +-- .../forks/monad_next/state_tracker.py | 31 ++++++++++++++++--- .../monad_next/vm/instructions/system.py | 4 --- .../conftest.py | 10 ++++++ .../eip7708_eth_transfer_logs/conftest.py | 10 ++++++ .../conftest.py | 10 ++++++ tests/amsterdam/eip7843_slotnum/conftest.py | 10 ++++++ .../conftest.py | 10 ++++++ .../conftest.py | 7 +++++ .../conftest.py | 10 ++++++ .../eip8024_dupn_swapn_exchange/conftest.py | 10 ++++++ .../conftest.py | 10 ++++++ .../conftest.py | 10 ++++++ .../eip8070_sparse_blobpool/conftest.py | 7 +++++ .../conftest.py | 9 ++++++ .../test_transfers.py | 11 ++++++- 20 files changed, 193 insertions(+), 21 deletions(-) create mode 100644 tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/conftest.py create mode 100644 tests/amsterdam/eip7708_eth_transfer_logs/conftest.py create mode 100644 tests/amsterdam/eip7778_block_gas_accounting_without_refunds/conftest.py create mode 100644 tests/amsterdam/eip7843_slotnum/conftest.py create mode 100644 tests/amsterdam/eip7928_block_level_access_lists/conftest.py create mode 100644 tests/amsterdam/eip7997_deterministic_factory_predeploy/conftest.py create mode 100644 tests/amsterdam/eip8024_dupn_swapn_exchange/conftest.py create mode 100644 tests/amsterdam/eip8037_state_creation_gas_cost_increase/conftest.py create mode 100644 tests/amsterdam/eip8038_state_access_gas_cost_increase/conftest.py diff --git a/.github/configs/feature.yaml b/.github/configs/feature.yaml index 7fca18fd9ef..36ab811fdb9 100644 --- a/.github/configs/feature.yaml +++ b/.github/configs/feature.yaml @@ -11,3 +11,7 @@ monad_runloop: evm-type: eels # Like `monad`, but `--monad-runloop` and eestnet chain id `30143` fill-params: -m blockchain_test --from=MONAD_EIGHT --until=MONAD_TEN --chain-id=30143 --monad-runloop -k "not invalid_header" + +monad_eip8246: + evm-type: eels + fill-params: -m blockchain_test --fork=MONAD_NEXT --chain-id=143 -k "not invalid_header" diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/forks.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/forks.py index 6075481806c..29ef4dc86e3 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/forks.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/forks.py @@ -1265,7 +1265,9 @@ def _process_with_marker_args( "Missing fork argument with 'valid_at_transition_to' marker." ) - if len(forks) > 1: + # A single EIP argument expands to one fork per enabling fork, so + # the limit is on the arguments rather than on the resolved forks. + if len(fork_args) > 1: raise Exception( "Too many forks specified to 'valid_at_transition_to' marker." ) diff --git a/packages/testing/src/execution_testing/forks/base_fork.py b/packages/testing/src/execution_testing/forks/base_fork.py index f842878c42f..808d94b6045 100644 --- a/packages/testing/src/execution_testing/forks/base_fork.py +++ b/packages/testing/src/execution_testing/forks/base_fork.py @@ -333,12 +333,23 @@ def _maybe_transitioned(fork_cls: "BaseForkMeta") -> "BaseForkMeta": @staticmethod def _is_subclass_of(a: "BaseForkMeta", b: "BaseForkMeta") -> bool: """ - Check if `a` is a subclass of `b`, taking fork transitions into - account. + Check if `a` is a subclass of `b`, taking fork transitions and + declared succession into account. + + A fork can follow another fork it does not inherit from, which + places it after that fork (and after everything that fork comes + after) in the fork order without adopting its behavior. """ a = BaseForkMeta._maybe_transitioned(a) b = BaseForkMeta._maybe_transitioned(b) - return issubclass(a, b) + if issubclass(a, b): + return True + followed = getattr(a, "_follows", None) + while followed is not None: + if issubclass(followed, b): + return True + followed = followed._follows + return False def __gt__(cls, other: "BaseForkMeta") -> bool: """Compare if a fork is newer than some other fork (cls > other).""" @@ -381,6 +392,7 @@ class BaseFork(ForkOpcodeInterface, metaclass=BaseForkMeta): _fork_by_timestamp: ClassVar[bool] = False _blob_constants: ClassVar[Dict[str, int]] = {} _deployed: ClassVar[bool] = True + _follows: ClassVar[Optional[Type["BaseFork"]]] = None _enabled_eips: ClassVar[Set[int]] = set() _enabling_forks: ClassVar[Set[Type["BaseFork"]]] = set() @@ -396,6 +408,7 @@ def __init_subclass__( transition_tool_name: Optional[str] = None, ignore: bool = False, bpo_fork: bool = False, + follows: Optional[Type["BaseFork"]] = None, ruleset_name: Optional[str] = None, fork_by_timestamp: Optional[bool] = None, deployed: Optional[bool] = None, @@ -410,6 +423,7 @@ def __init_subclass__( forks. """ cls._transition_tool_name = transition_tool_name + cls._follows = follows cls._ignore = ignore cls._bpo_fork = bpo_fork cls._ruleset_name = ruleset_name diff --git a/packages/testing/src/execution_testing/forks/forks/forks.py b/packages/testing/src/execution_testing/forks/forks/forks.py index ecf3b02f3ae..618ee8a0000 100644 --- a/packages/testing/src/execution_testing/forks/forks/forks.py +++ b/packages/testing/src/execution_testing/forks/forks/forks.py @@ -1805,12 +1805,6 @@ def _calculate_sstore_gas_mip8( return gas_cost -class MONAD_NEXT(MONAD_TEN): # noqa: N801 - """MONAD_NEXT fork, a placeholder identical to MONAD_TEN.""" - - pass - - class BPO1( Osaka, bpo_fork=True, @@ -1904,3 +1898,20 @@ def engine_payload_attribute_target_gas_limit(cls) -> bool: limit. """ return True + + +class MONAD_NEXT( # noqa: N801 + eips.EIP8246, + MONAD_TEN, + follows=Amsterdam, +): + """ + MONAD_NEXT fork. + + Amsterdam-based successor to MONAD_TEN, adopting the EIP-8246 + changes. The Amsterdam changes it does not adopt stay out of the + fork by not being inherited at all; the fork order still places + MONAD_NEXT after Amsterdam through `follows`. + """ + + pass diff --git a/src/ethereum/forks/monad_next/fork.py b/src/ethereum/forks/monad_next/fork.py index 653f05d030f..7b27bf2d821 100644 --- a/src/ethereum/forks/monad_next/fork.py +++ b/src/ethereum/forks/monad_next/fork.py @@ -61,8 +61,8 @@ BlockState, TransactionState, add_sender_authority, + clear_account_preserving_balance, create_ether, - destroy_account, extract_block_diff, forget_senders_authorities, get_account, @@ -974,7 +974,7 @@ def process_transaction( create_ether(tx_state, block_env.coinbase, U256(transaction_fee)) for address in tx_output.accounts_to_delete: - destroy_account(tx_state, address) + clear_account_preserving_balance(tx_state, address) # block_output.block_gas_used += tx_gas_used_after_refund block_output.block_gas_used += tx.gas diff --git a/src/ethereum/forks/monad_next/state_tracker.py b/src/ethereum/forks/monad_next/state_tracker.py index 5b3d4be0bd2..1d0914461d2 100644 --- a/src/ethereum/forks/monad_next/state_tracker.py +++ b/src/ethereum/forks/monad_next/state_tracker.py @@ -423,10 +423,9 @@ def destroy_account(tx_state: TransactionState, address: Address) -> None: """ Completely remove the account at ``address`` and all of its storage. - This function is made available exclusively for the ``SELFDESTRUCT`` - opcode. It is expected that ``SELFDESTRUCT`` will be disabled in a - future hardfork and this function will be removed. Only supports same - transaction destruction. + Invoked by ``modify_state`` (and the coinbase fee-credit path) to + clean up an account that has become empty (zero nonce, empty + code, and zero balance) so it does not appear in the post-state. Parameters ---------- @@ -440,6 +439,30 @@ def destroy_account(tx_state: TransactionState, address: Address) -> None: set_account(tx_state, address, None) +def clear_account_preserving_balance( + tx_state: TransactionState, address: Address +) -> None: + """ + Clear an account's nonce, code, and storage while preserving its + balance. + + Parameters + ---------- + tx_state : + The transaction state. + address : + Address of the account to modify. + + """ + + def clear_account(account: Account) -> None: + account.nonce = Uint(0) + account.code_hash = EMPTY_CODE_HASH + + destroy_storage(tx_state, address) + modify_state(tx_state, address, clear_account) + + def destroy_storage(tx_state: TransactionState, address: Address) -> None: """ Completely remove the storage at ``address``. diff --git a/src/ethereum/forks/monad_next/vm/instructions/system.py b/src/ethereum/forks/monad_next/vm/instructions/system.py index 7b8634c5d7e..764bcec38fe 100644 --- a/src/ethereum/forks/monad_next/vm/instructions/system.py +++ b/src/ethereum/forks/monad_next/vm/instructions/system.py @@ -24,7 +24,6 @@ increment_nonce, is_account_alive, move_ether, - set_account_balance, ) from ...utils.address import ( compute_contract_address, @@ -587,9 +586,6 @@ def selfdestruct(evm: Evm) -> None: # register account for deletion only if it was created # in the same transaction if originator in evm.message.tx_env.state.created_accounts: - # If beneficiary is the same as originator, then - # the ether is burnt. - set_account_balance(evm.message.tx_env.state, originator, U256(0)) evm.accounts_to_delete.add(originator) # HALT the execution diff --git a/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/conftest.py b/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/conftest.py new file mode 100644 index 00000000000..de52075d396 --- /dev/null +++ b/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/conftest.py @@ -0,0 +1,10 @@ +"""Pytest (plugin) definitions local to EIP-2780 tests.""" + +import pytest + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Mark all tests in this subdir as not valid for Monad forks.""" + metafunc.definition.add_marker( + pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) + ) diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/conftest.py b/tests/amsterdam/eip7708_eth_transfer_logs/conftest.py new file mode 100644 index 00000000000..fb1d9465b3e --- /dev/null +++ b/tests/amsterdam/eip7708_eth_transfer_logs/conftest.py @@ -0,0 +1,10 @@ +"""Pytest (plugin) definitions local to EIP-7708 tests.""" + +import pytest + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Mark all tests in this subdir as not valid for Monad forks.""" + metafunc.definition.add_marker( + pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) + ) diff --git a/tests/amsterdam/eip7778_block_gas_accounting_without_refunds/conftest.py b/tests/amsterdam/eip7778_block_gas_accounting_without_refunds/conftest.py new file mode 100644 index 00000000000..5cbdebdc9ef --- /dev/null +++ b/tests/amsterdam/eip7778_block_gas_accounting_without_refunds/conftest.py @@ -0,0 +1,10 @@ +"""Pytest (plugin) definitions local to EIP-7778 tests.""" + +import pytest + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Mark all tests in this subdir as not valid for Monad forks.""" + metafunc.definition.add_marker( + pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) + ) diff --git a/tests/amsterdam/eip7843_slotnum/conftest.py b/tests/amsterdam/eip7843_slotnum/conftest.py new file mode 100644 index 00000000000..6e43f18fb35 --- /dev/null +++ b/tests/amsterdam/eip7843_slotnum/conftest.py @@ -0,0 +1,10 @@ +"""Pytest (plugin) definitions local to EIP-7843 tests.""" + +import pytest + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Mark all tests in this subdir as not valid for Monad forks.""" + metafunc.definition.add_marker( + pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) + ) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/conftest.py b/tests/amsterdam/eip7928_block_level_access_lists/conftest.py new file mode 100644 index 00000000000..3bedcfe2651 --- /dev/null +++ b/tests/amsterdam/eip7928_block_level_access_lists/conftest.py @@ -0,0 +1,10 @@ +"""Pytest (plugin) definitions local to EIP-7928 tests.""" + +import pytest + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Mark all tests in this subdir as not valid for Monad forks.""" + metafunc.definition.add_marker( + pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) + ) diff --git a/tests/amsterdam/eip7976_increase_calldata_floor_cost/conftest.py b/tests/amsterdam/eip7976_increase_calldata_floor_cost/conftest.py index f92a0e003a0..9a252257297 100644 --- a/tests/amsterdam/eip7976_increase_calldata_floor_cost/conftest.py +++ b/tests/amsterdam/eip7976_increase_calldata_floor_cost/conftest.py @@ -370,3 +370,10 @@ def tx( blob_versioned_hashes=blob_versioned_hashes, error=tx_error, ) + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Mark all tests in this subdir as not valid for Monad forks.""" + metafunc.definition.add_marker( + pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) + ) diff --git a/tests/amsterdam/eip7997_deterministic_factory_predeploy/conftest.py b/tests/amsterdam/eip7997_deterministic_factory_predeploy/conftest.py new file mode 100644 index 00000000000..de93abfcfef --- /dev/null +++ b/tests/amsterdam/eip7997_deterministic_factory_predeploy/conftest.py @@ -0,0 +1,10 @@ +"""Pytest (plugin) definitions local to EIP-7997 tests.""" + +import pytest + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Mark all tests in this subdir as not valid for Monad forks.""" + metafunc.definition.add_marker( + pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) + ) diff --git a/tests/amsterdam/eip8024_dupn_swapn_exchange/conftest.py b/tests/amsterdam/eip8024_dupn_swapn_exchange/conftest.py new file mode 100644 index 00000000000..d8c781bb489 --- /dev/null +++ b/tests/amsterdam/eip8024_dupn_swapn_exchange/conftest.py @@ -0,0 +1,10 @@ +"""Pytest (plugin) definitions local to EIP-8024 tests.""" + +import pytest + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Mark all tests in this subdir as not valid for Monad forks.""" + metafunc.definition.add_marker( + pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) + ) diff --git a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/conftest.py b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/conftest.py new file mode 100644 index 00000000000..2d16c275db4 --- /dev/null +++ b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/conftest.py @@ -0,0 +1,10 @@ +"""Pytest (plugin) definitions local to EIP-8037 tests.""" + +import pytest + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Mark all tests in this subdir as not valid for Monad forks.""" + metafunc.definition.add_marker( + pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) + ) diff --git a/tests/amsterdam/eip8038_state_access_gas_cost_increase/conftest.py b/tests/amsterdam/eip8038_state_access_gas_cost_increase/conftest.py new file mode 100644 index 00000000000..cd29eb50dcb --- /dev/null +++ b/tests/amsterdam/eip8038_state_access_gas_cost_increase/conftest.py @@ -0,0 +1,10 @@ +"""Pytest (plugin) definitions local to EIP-8038 tests.""" + +import pytest + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Mark all tests in this subdir as not valid for Monad forks.""" + metafunc.definition.add_marker( + pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) + ) diff --git a/tests/amsterdam/eip8070_sparse_blobpool/conftest.py b/tests/amsterdam/eip8070_sparse_blobpool/conftest.py index cb2a757494c..dc3ee7c3ecd 100644 --- a/tests/amsterdam/eip8070_sparse_blobpool/conftest.py +++ b/tests/amsterdam/eip8070_sparse_blobpool/conftest.py @@ -148,3 +148,10 @@ def txs( ) txs.append(network_wrapped_tx) return txs + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Mark all tests in this subdir as not valid for Monad forks.""" + metafunc.definition.add_marker( + pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) + ) diff --git a/tests/amsterdam/eip8282_builder_execution_requests/conftest.py b/tests/amsterdam/eip8282_builder_execution_requests/conftest.py index e17cee467fc..7a42ba76785 100644 --- a/tests/amsterdam/eip8282_builder_execution_requests/conftest.py +++ b/tests/amsterdam/eip8282_builder_execution_requests/conftest.py @@ -1,8 +1,17 @@ """Fixtures for the EIP-8282 builder execution request tests.""" +import pytest + from ...common.system_contract_request_fixtures import ( blocks, # noqa: F401 included_requests, # noqa: F401 system_contract_interactions_per_block_copy, # noqa: F401 timestamp, # noqa: F401 ) + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Mark all tests in this subdir as not valid for Monad forks.""" + metafunc.definition.add_marker( + pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) + ) diff --git a/tests/monad_nine/mip4_checkreservebalance/test_transfers.py b/tests/monad_nine/mip4_checkreservebalance/test_transfers.py index ec43d75dd91..c217fd2cd6e 100644 --- a/tests/monad_nine/mip4_checkreservebalance/test_transfers.py +++ b/tests/monad_nine/mip4_checkreservebalance/test_transfers.py @@ -1462,6 +1462,15 @@ def test_contract_unrestricted_within_initcode( new_balance = balance - value + Spec.RESERVE_BALANCE + # EIP-8246 stops the end-of-transaction cleanup from burning the + # balance, so the refill that follows SELFDESTRUCT is left behind on + # a cleared account. + selfdestructed_account = ( + Account(nonce=0, balance=Spec.RESERVE_BALANCE, code=b"", storage={}) + if fork.is_eip_enabled(8246) + else None + ) + txs = [tx_1] if new_address_pre_funded: txs.insert( @@ -1491,7 +1500,7 @@ def test_contract_unrestricted_within_initcode( balance=new_balance, code=deploy_code ) if not selfdestruct - else None, + else selfdestructed_account, target: Account(balance=value) if value != 0 else None, # SELFDESTRUCT runs during initcode (before factory # refill), so it sends balance - value only. From 08ac088efc697c52b9843427b5228b2c5fdaf04b Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:15:37 +0000 Subject: [PATCH 11/28] test(selfdestruct): expect the Burn logs a fork without EIP-8246 emits A fork can enable EIP-7708 without EIP-8246 and still burn a swept or stranded balance, which the sweep helper now accounts for. Co-Authored-By: Claude --- .../eip7708_eth_transfer_logs/spec.py | 13 ++ .../conftest.py | 7 - .../conftest.py | 7 - .../eip6780_selfdestruct/test_selfdestruct.py | 144 +++++++++++------- 4 files changed, 101 insertions(+), 70 deletions(-) diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/spec.py b/tests/amsterdam/eip7708_eth_transfer_logs/spec.py index 56a42e2850a..11ba0ebf214 100644 --- a/tests/amsterdam/eip7708_eth_transfer_logs/spec.py +++ b/tests/amsterdam/eip7708_eth_transfer_logs/spec.py @@ -30,6 +30,7 @@ class Spec: TRANSFER_TOPIC: Hash = Hash( keccak256(b"Transfer(address,address,uint256)") ) + BURN_TOPIC: Hash = Hash(keccak256(b"Burn(address,uint256)")) def transfer_log( @@ -45,3 +46,15 @@ def transfer_log( ], data=Bytes(amount.to_bytes(32, "big")), ) + + +def burn_log(contract_address: Address, amount: int) -> TransactionLog: + """Create an expected Burn log for EIP-7708.""" + return TransactionLog( + address=Spec.SYSTEM_ADDRESS, + topics=[ + Spec.BURN_TOPIC, + Hash(bytes(contract_address).rjust(32, b"\x00")), + ], + data=Bytes(amount.to_bytes(32, "big")), + ) diff --git a/tests/amsterdam/eip7954_increase_max_contract_size/conftest.py b/tests/amsterdam/eip7954_increase_max_contract_size/conftest.py index b4d596bdc52..78cb66ed14d 100644 --- a/tests/amsterdam/eip7954_increase_max_contract_size/conftest.py +++ b/tests/amsterdam/eip7954_increase_max_contract_size/conftest.py @@ -4,13 +4,6 @@ from execution_testing import Address, Alloc, Bytecode, Fork, Op -def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: - """Mark all tests in this subdir as not valid for Monad forks.""" - metafunc.definition.add_marker( - pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) - ) - - @pytest.fixture def max_code_size_contract( pre: Alloc, diff --git a/tests/amsterdam/eip7981_increase_access_list_cost/conftest.py b/tests/amsterdam/eip7981_increase_access_list_cost/conftest.py index c02d771f5d2..104a73e464a 100644 --- a/tests/amsterdam/eip7981_increase_access_list_cost/conftest.py +++ b/tests/amsterdam/eip7981_increase_access_list_cost/conftest.py @@ -22,13 +22,6 @@ from ...cancun.eip4844_blobs.spec import Spec as EIP_4844_Spec -def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: - """Mark all tests in this subdir as not valid for Monad forks.""" - metafunc.definition.add_marker( - pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) - ) - - @pytest.fixture def to( request: pytest.FixtureRequest, diff --git a/tests/cancun/eip6780_selfdestruct/test_selfdestruct.py b/tests/cancun/eip6780_selfdestruct/test_selfdestruct.py index 24a80ceca9e..21b2734529f 100644 --- a/tests/cancun/eip6780_selfdestruct/test_selfdestruct.py +++ b/tests/cancun/eip6780_selfdestruct/test_selfdestruct.py @@ -24,12 +24,16 @@ StateTestFiller, Storage, Transaction, + TransactionLog, TransactionReceipt, compute_create_address, ) from execution_testing.forks import MONAD_EIGHT, Cancun -from tests.amsterdam.eip7708_eth_transfer_logs.spec import transfer_log +from tests.amsterdam.eip7708_eth_transfer_logs.spec import ( + burn_log, + transfer_log, +) REFERENCE_SPEC_GIT_PATH = "EIPS/eip-6780.md" REFERENCE_SPEC_VERSION = "1b6a0e94cc47e859b9866e570391cf37dc55059a" @@ -52,6 +56,25 @@ PRE_DEPLOY_CONTRACT_3 = "pre_deploy_contract_3" +def sweep_log( + fork: Fork, + contract_address: Address, + recipient: Address, + amount: int, +) -> TransactionLog | None: + """ + Return the EIP-7708 log a SELFDESTRUCT sweep emits, if any. + + A sweep to another account transfers. A sweep to self burns the + balance, until EIP-8246 keeps it and emits nothing. + """ + if recipient != contract_address: + return transfer_log(contract_address, recipient, amount) + if fork.is_eip_enabled(8246): + return None + return burn_log(contract_address, amount) + + @pytest.fixture def eip_enabled(fork: Fork) -> bool: """Whether the EIP is enabled or not.""" @@ -324,14 +347,14 @@ def test_create_selfdestruct_same_tx( # SELFDESTRUCT emits a Transfer log to a different address, or a Burn # log when sending to self (contract was created in this tx). if selfdestruct_contract_current_balance > 0: - if sendall_recipient != selfdestruct_contract_address: - expected_logs_after_tx_value.append( - transfer_log( - selfdestruct_contract_address, - sendall_recipient, - selfdestruct_contract_current_balance, - ) - ) + sweep = sweep_log( + fork, + selfdestruct_contract_address, + sendall_recipient, + selfdestruct_contract_current_balance, + ) + if sweep is not None: + expected_logs_after_tx_value.append(sweep) # Balance is always sent to other contracts if sendall_recipient != selfdestruct_contract_address: @@ -569,13 +592,14 @@ def test_self_destructing_initcode( ) # Initcode SELFDESTRUCT sends pre-existing balance to the recipient. if selfdestruct_contract_initial_balance > 0: - expected_logs.append( - transfer_log( - selfdestruct_contract_address, - sendall_recipient_addresses[0], - selfdestruct_contract_initial_balance, - ) + sweep = sweep_log( + fork, + selfdestruct_contract_address, + sendall_recipient_addresses[0], + selfdestruct_contract_initial_balance, ) + if sweep is not None: + expected_logs.append(sweep) # CALLs to the destroyed contract transfer ETH to it. for i in range(call_times): if i > 0: @@ -584,6 +608,12 @@ def test_self_destructing_initcode( entry_code_address, selfdestruct_contract_address, i ) ) + # Whatever the calls left on the account is burned when the + # account is deleted, until EIP-8246 keeps it. + if entry_code_balance > 0 and not fork.is_eip_enabled(8246): + expected_logs.append( + burn_log(selfdestruct_contract_address, entry_code_balance) + ) tx.expected_receipt = TransactionReceipt(logs=expected_logs) state_test(pre=pre, post=post, tx=tx) @@ -657,13 +687,14 @@ def test_self_destructing_initcode_create_tx( transfer_log(sender, selfdestruct_contract_address, tx_value) ) if sendall_amount > 0: - expected_logs.append( - transfer_log( - selfdestruct_contract_address, - sendall_recipient_addresses[0], - sendall_amount, - ) + sweep = sweep_log( + fork, + selfdestruct_contract_address, + sendall_recipient_addresses[0], + sendall_amount, ) + if sweep is not None: + expected_logs.append(sweep) tx.expected_receipt = TransactionReceipt(logs=expected_logs) state_test(pre=pre, post=post, tx=tx) @@ -786,17 +817,14 @@ def test_recreate_self_destructed_contract_different_txs( # address with 0 balance (destroyed+cleared), so no log. tx_logs: list = [] if i == 0 and selfdestruct_contract_initial_balance > 0: - if ( - sendall_recipient_addresses[0] - != selfdestruct_contract_address - ): - tx_logs.append( - transfer_log( - selfdestruct_contract_address, - sendall_recipient_addresses[0], - selfdestruct_contract_initial_balance, - ) - ) + sweep = sweep_log( + fork, + selfdestruct_contract_address, + sendall_recipient_addresses[0], + selfdestruct_contract_initial_balance, + ) + if sweep is not None: + tx_logs.append(sweep) expected_receipt = TransactionReceipt(logs=tx_logs) txs.append( Transaction( @@ -988,13 +1016,14 @@ def test_selfdestruct_pre_existing( sendall_recipient != selfdestruct_contract_address and selfdestruct_contract_current_balance > 0 ): - expected_logs_after_tx_value.append( - transfer_log( - selfdestruct_contract_address, - sendall_recipient, - selfdestruct_contract_current_balance, - ) + sweep = sweep_log( + fork, + selfdestruct_contract_address, + sendall_recipient, + selfdestruct_contract_current_balance, ) + if sweep is not None: + expected_logs_after_tx_value.append(sweep) # Balance is always sent to other contracts if sendall_recipient != selfdestruct_contract_address: @@ -1192,13 +1221,14 @@ def test_selfdestruct_created_same_block_different_tx( ) running_balance += i if running_balance > 0: - tx2_logs.append( - transfer_log( - selfdestruct_contract_address, - sendall_recipient_addresses[0], - running_balance, - ) + sweep = sweep_log( + fork, + selfdestruct_contract_address, + sendall_recipient_addresses[0], + running_balance, ) + if sweep is not None: + tx2_logs.append(sweep) running_balance = 0 tx2_receipt = TransactionReceipt(logs=tx2_logs) @@ -1384,13 +1414,14 @@ def test_calling_from_new_contract_to_pre_existing_contract( ) running_balance += i if running_balance > 0: - expected_logs.append( - transfer_log( - selfdestruct_contract_address, - sendall_recipient_addresses[0], - running_balance, - ) + sweep = sweep_log( + fork, + selfdestruct_contract_address, + sendall_recipient_addresses[0], + running_balance, ) + if sweep is not None: + expected_logs.append(sweep) running_balance = 0 tx.expected_receipt = TransactionReceipt(logs=expected_logs) @@ -1728,13 +1759,14 @@ def test_create_selfdestruct_same_tx_increased_nonce( # (SELF_ADDRESS is not parametrized here), so a Transfer log is # emitted whenever the contract has a nonzero balance. if selfdestruct_contract_current_balance > 0: - expected_logs_after_tx_value.append( - transfer_log( - selfdestruct_contract_address, - sendall_recipient, - selfdestruct_contract_current_balance, - ) + sweep = sweep_log( + fork, + selfdestruct_contract_address, + sendall_recipient, + selfdestruct_contract_current_balance, ) + if sweep is not None: + expected_logs_after_tx_value.append(sweep) # Balance is always sent to other contracts if sendall_recipient != selfdestruct_contract_address: From ea4b01d22c5329072bf2dea2be6e18a77aa8c60f Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:58:59 +0000 Subject: [PATCH 12/28] feat(monad_next): adopt EIP-7981 increase access list cost MONAD_NEXT charges access list bytes at its own floor token rate, ordered after Amsterdam by succession, and releases as the monad_eip7981 feature. Co-Authored-By: Claude --- .github/configs/feature.yaml | 4 +++ .../pytest_commands/plugins/forks/forks.py | 4 ++- .../src/execution_testing/forks/base_fork.py | 20 +++++++++-- .../execution_testing/forks/forks/forks.py | 23 ++++++++---- src/ethereum/forks/monad_next/transactions.py | 36 ++++++++++++++++--- .../conftest.py | 10 ++++++ .../eip7708_eth_transfer_logs/conftest.py | 10 ++++++ .../conftest.py | 10 ++++++ tests/amsterdam/eip7843_slotnum/conftest.py | 10 ++++++ .../conftest.py | 10 ++++++ .../conftest.py | 7 ++++ .../test_access_list_cost.py | 5 +-- .../test_fork_transition.py | 16 +++++---- .../conftest.py | 10 ++++++ .../eip8024_dupn_swapn_exchange/conftest.py | 10 ++++++ .../conftest.py | 10 ++++++ .../conftest.py | 10 ++++++ .../eip8070_sparse_blobpool/conftest.py | 7 ++++ .../eip8246_selfdestruct_no_burn/conftest.py | 10 ++++++ .../conftest.py | 9 +++++ 20 files changed, 208 insertions(+), 23 deletions(-) create mode 100644 tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/conftest.py create mode 100644 tests/amsterdam/eip7708_eth_transfer_logs/conftest.py create mode 100644 tests/amsterdam/eip7778_block_gas_accounting_without_refunds/conftest.py create mode 100644 tests/amsterdam/eip7843_slotnum/conftest.py create mode 100644 tests/amsterdam/eip7928_block_level_access_lists/conftest.py create mode 100644 tests/amsterdam/eip7997_deterministic_factory_predeploy/conftest.py create mode 100644 tests/amsterdam/eip8024_dupn_swapn_exchange/conftest.py create mode 100644 tests/amsterdam/eip8037_state_creation_gas_cost_increase/conftest.py create mode 100644 tests/amsterdam/eip8038_state_access_gas_cost_increase/conftest.py create mode 100644 tests/amsterdam/eip8246_selfdestruct_no_burn/conftest.py diff --git a/.github/configs/feature.yaml b/.github/configs/feature.yaml index 7fca18fd9ef..7849a5e116a 100644 --- a/.github/configs/feature.yaml +++ b/.github/configs/feature.yaml @@ -11,3 +11,7 @@ monad_runloop: evm-type: eels # Like `monad`, but `--monad-runloop` and eestnet chain id `30143` fill-params: -m blockchain_test --from=MONAD_EIGHT --until=MONAD_TEN --chain-id=30143 --monad-runloop -k "not invalid_header" + +monad_eip7981: + evm-type: eels + fill-params: -m blockchain_test --fork=MONAD_NEXT --chain-id=143 -k "not invalid_header" diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/forks.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/forks.py index 6075481806c..29ef4dc86e3 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/forks.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/forks.py @@ -1265,7 +1265,9 @@ def _process_with_marker_args( "Missing fork argument with 'valid_at_transition_to' marker." ) - if len(forks) > 1: + # A single EIP argument expands to one fork per enabling fork, so + # the limit is on the arguments rather than on the resolved forks. + if len(fork_args) > 1: raise Exception( "Too many forks specified to 'valid_at_transition_to' marker." ) diff --git a/packages/testing/src/execution_testing/forks/base_fork.py b/packages/testing/src/execution_testing/forks/base_fork.py index f842878c42f..808d94b6045 100644 --- a/packages/testing/src/execution_testing/forks/base_fork.py +++ b/packages/testing/src/execution_testing/forks/base_fork.py @@ -333,12 +333,23 @@ def _maybe_transitioned(fork_cls: "BaseForkMeta") -> "BaseForkMeta": @staticmethod def _is_subclass_of(a: "BaseForkMeta", b: "BaseForkMeta") -> bool: """ - Check if `a` is a subclass of `b`, taking fork transitions into - account. + Check if `a` is a subclass of `b`, taking fork transitions and + declared succession into account. + + A fork can follow another fork it does not inherit from, which + places it after that fork (and after everything that fork comes + after) in the fork order without adopting its behavior. """ a = BaseForkMeta._maybe_transitioned(a) b = BaseForkMeta._maybe_transitioned(b) - return issubclass(a, b) + if issubclass(a, b): + return True + followed = getattr(a, "_follows", None) + while followed is not None: + if issubclass(followed, b): + return True + followed = followed._follows + return False def __gt__(cls, other: "BaseForkMeta") -> bool: """Compare if a fork is newer than some other fork (cls > other).""" @@ -381,6 +392,7 @@ class BaseFork(ForkOpcodeInterface, metaclass=BaseForkMeta): _fork_by_timestamp: ClassVar[bool] = False _blob_constants: ClassVar[Dict[str, int]] = {} _deployed: ClassVar[bool] = True + _follows: ClassVar[Optional[Type["BaseFork"]]] = None _enabled_eips: ClassVar[Set[int]] = set() _enabling_forks: ClassVar[Set[Type["BaseFork"]]] = set() @@ -396,6 +408,7 @@ def __init_subclass__( transition_tool_name: Optional[str] = None, ignore: bool = False, bpo_fork: bool = False, + follows: Optional[Type["BaseFork"]] = None, ruleset_name: Optional[str] = None, fork_by_timestamp: Optional[bool] = None, deployed: Optional[bool] = None, @@ -410,6 +423,7 @@ def __init_subclass__( forks. """ cls._transition_tool_name = transition_tool_name + cls._follows = follows cls._ignore = ignore cls._bpo_fork = bpo_fork cls._ruleset_name = ruleset_name diff --git a/packages/testing/src/execution_testing/forks/forks/forks.py b/packages/testing/src/execution_testing/forks/forks/forks.py index ecf3b02f3ae..04fde59cc0c 100644 --- a/packages/testing/src/execution_testing/forks/forks/forks.py +++ b/packages/testing/src/execution_testing/forks/forks/forks.py @@ -1805,12 +1805,6 @@ def _calculate_sstore_gas_mip8( return gas_cost -class MONAD_NEXT(MONAD_TEN): # noqa: N801 - """MONAD_NEXT fork, a placeholder identical to MONAD_TEN.""" - - pass - - class BPO1( Osaka, bpo_fork=True, @@ -1904,3 +1898,20 @@ def engine_payload_attribute_target_gas_limit(cls) -> bool: limit. """ return True + + +class MONAD_NEXT( # noqa: N801 + eips.EIP7981, + MONAD_TEN, + follows=Amsterdam, +): + """ + MONAD_NEXT fork. + + Amsterdam-based successor to MONAD_TEN, adopting the EIP-7981 + changes. The Amsterdam changes it does not adopt stay out of the + fork by not being inherited at all; the fork order still places + MONAD_NEXT after Amsterdam through `follows`. + """ + + pass diff --git a/src/ethereum/forks/monad_next/transactions.py b/src/ethereum/forks/monad_next/transactions.py index 24bb4880a74..71bce9409f3 100644 --- a/src/ethereum/forks/monad_next/transactions.py +++ b/src/ethereum/forks/monad_next/transactions.py @@ -30,6 +30,22 @@ TX_MAX_GAS_LIMIT = Uint(30_000_000) +ACCESS_LIST_ADDRESS_FLOOR_TOKENS = Uint(80) +""" +Floor data tokens contributed by a single access list address per +[EIP-7981]. + +[EIP-7981]: https://eips.ethereum.org/EIPS/eip-7981 +""" + +ACCESS_LIST_STORAGE_KEY_FLOOR_TOKENS = Uint(128) +""" +Floor data tokens contributed by a single access list storage key per +[EIP-7981]. + +[EIP-7981]: https://eips.ethereum.org/EIPS/eip-7981 +""" + @final @slotted_freezable @@ -587,10 +603,6 @@ def calculate_intrinsic_cost(tx: Transaction) -> Tuple[Uint, Uint]: num_non_zeros = ulen(tx.data) - num_zeros tokens_in_calldata = num_zeros + num_non_zeros * Uint(4) - # EIP-7623 floor price (note: no EVM costs) - calldata_floor_gas_cost = ( - tokens_in_calldata * GasCosts.TX_DATA_TOKEN_FLOOR + GasCosts.TX_BASE - ) data_cost = tokens_in_calldata * GasCosts.TX_DATA_TOKEN_STANDARD @@ -600,12 +612,28 @@ def calculate_intrinsic_cost(tx: Transaction) -> Tuple[Uint, Uint]: create_cost = Uint(0) access_list_cost = Uint(0) + tokens_in_access_list = Uint(0) if has_access_list(tx): for access in tx.access_list: access_list_cost += GasCosts.TX_ACCESS_LIST_ADDRESS access_list_cost += ( ulen(access.slots) * GasCosts.TX_ACCESS_LIST_STORAGE_KEY ) + tokens_in_access_list += ACCESS_LIST_ADDRESS_FLOOR_TOKENS + tokens_in_access_list += ( + ulen(access.slots) * ACCESS_LIST_STORAGE_KEY_FLOOR_TOKENS + ) + + # Data token floor cost for access list bytes. + access_list_cost += tokens_in_access_list * GasCosts.TX_DATA_TOKEN_FLOOR + + # Total floor tokens. + total_floor_tokens = tokens_in_calldata + tokens_in_access_list + + # EIP-7623 floor price (note: no EVM costs) + calldata_floor_gas_cost = ( + total_floor_tokens * GasCosts.TX_DATA_TOKEN_FLOOR + GasCosts.TX_BASE + ) auth_cost = Uint(0) if isinstance(tx, SetCodeTransaction): diff --git a/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/conftest.py b/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/conftest.py new file mode 100644 index 00000000000..de52075d396 --- /dev/null +++ b/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/conftest.py @@ -0,0 +1,10 @@ +"""Pytest (plugin) definitions local to EIP-2780 tests.""" + +import pytest + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Mark all tests in this subdir as not valid for Monad forks.""" + metafunc.definition.add_marker( + pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) + ) diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/conftest.py b/tests/amsterdam/eip7708_eth_transfer_logs/conftest.py new file mode 100644 index 00000000000..fb1d9465b3e --- /dev/null +++ b/tests/amsterdam/eip7708_eth_transfer_logs/conftest.py @@ -0,0 +1,10 @@ +"""Pytest (plugin) definitions local to EIP-7708 tests.""" + +import pytest + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Mark all tests in this subdir as not valid for Monad forks.""" + metafunc.definition.add_marker( + pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) + ) diff --git a/tests/amsterdam/eip7778_block_gas_accounting_without_refunds/conftest.py b/tests/amsterdam/eip7778_block_gas_accounting_without_refunds/conftest.py new file mode 100644 index 00000000000..5cbdebdc9ef --- /dev/null +++ b/tests/amsterdam/eip7778_block_gas_accounting_without_refunds/conftest.py @@ -0,0 +1,10 @@ +"""Pytest (plugin) definitions local to EIP-7778 tests.""" + +import pytest + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Mark all tests in this subdir as not valid for Monad forks.""" + metafunc.definition.add_marker( + pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) + ) diff --git a/tests/amsterdam/eip7843_slotnum/conftest.py b/tests/amsterdam/eip7843_slotnum/conftest.py new file mode 100644 index 00000000000..6e43f18fb35 --- /dev/null +++ b/tests/amsterdam/eip7843_slotnum/conftest.py @@ -0,0 +1,10 @@ +"""Pytest (plugin) definitions local to EIP-7843 tests.""" + +import pytest + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Mark all tests in this subdir as not valid for Monad forks.""" + metafunc.definition.add_marker( + pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) + ) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/conftest.py b/tests/amsterdam/eip7928_block_level_access_lists/conftest.py new file mode 100644 index 00000000000..3bedcfe2651 --- /dev/null +++ b/tests/amsterdam/eip7928_block_level_access_lists/conftest.py @@ -0,0 +1,10 @@ +"""Pytest (plugin) definitions local to EIP-7928 tests.""" + +import pytest + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Mark all tests in this subdir as not valid for Monad forks.""" + metafunc.definition.add_marker( + pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) + ) diff --git a/tests/amsterdam/eip7976_increase_calldata_floor_cost/conftest.py b/tests/amsterdam/eip7976_increase_calldata_floor_cost/conftest.py index f92a0e003a0..9a252257297 100644 --- a/tests/amsterdam/eip7976_increase_calldata_floor_cost/conftest.py +++ b/tests/amsterdam/eip7976_increase_calldata_floor_cost/conftest.py @@ -370,3 +370,10 @@ def tx( blob_versioned_hashes=blob_versioned_hashes, error=tx_error, ) + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Mark all tests in this subdir as not valid for Monad forks.""" + metafunc.definition.add_marker( + pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) + ) diff --git a/tests/amsterdam/eip7981_increase_access_list_cost/test_access_list_cost.py b/tests/amsterdam/eip7981_increase_access_list_cost/test_access_list_cost.py index 2d093e6d0bb..981617c0ec0 100644 --- a/tests/amsterdam/eip7981_increase_access_list_cost/test_access_list_cost.py +++ b/tests/amsterdam/eip7981_increase_access_list_cost/test_access_list_cost.py @@ -124,10 +124,11 @@ def test_access_list_token_calculation( expected_floor_cost = ( expected_floor_tokens * gas_costs.TX_DATA_TOKEN_FLOOR + gas_costs.TX_BASE + ) + if fork.is_eip_enabled(2780): # EIP-2780 anchors the floor on the decomposed intrinsic base; the # tx targets a non-self account, adding the recipient-access charge. - + gas_costs.COLD_ACCOUNT_ACCESS - ) + expected_floor_cost += gas_costs.COLD_ACCOUNT_ACCESS actual_floor_cost = fork.transaction_data_floor_cost_calculator()( data=b"", access_list=access_list ) diff --git a/tests/amsterdam/eip7981_increase_access_list_cost/test_fork_transition.py b/tests/amsterdam/eip7981_increase_access_list_cost/test_fork_transition.py index 62a899b346a..92b02d7def7 100644 --- a/tests/amsterdam/eip7981_increase_access_list_cost/test_fork_transition.py +++ b/tests/amsterdam/eip7981_increase_access_list_cost/test_fork_transition.py @@ -7,12 +7,13 @@ transition timestamp) and pin the per-transaction gas paid on each side, plus the validity flip for gas limits inside the uplift gap. -The post-fork intrinsic composes three repricings; the hand-derived +The post-fork intrinsic composes up to three repricings; the hand-derived expectations below keep each term explicit so the EIP-7981 surcharge is individually visible: -- EIP-2780 decomposes the flat pre-fork `TX_BASE` into the lowered base - plus the `COLD_ACCOUNT_ACCESS` recipient charge. +- EIP-2780, on the forks that enable it, decomposes the flat pre-fork + `TX_BASE` into the lowered base plus the `COLD_ACCOUNT_ACCESS` + recipient charge. - EIP-8038 reprices the per-address and per-storage-key access list charges to the fork's cold access costs. - EIP-7981 adds four floor tokens per access list byte, charged at @@ -116,11 +117,12 @@ def test_access_list_intrinsic_across_amsterdam_transition( ) expected_post = ( post_costs.TX_BASE - + post_costs.COLD_ACCOUNT_ACCESS + addresses * post_costs.TX_ACCESS_LIST_ADDRESS + total_keys * post_costs.TX_ACCESS_LIST_STORAGE_KEY + surcharge ) + if post_fork.is_eip_enabled(2780): + expected_post += post_costs.COLD_ACCOUNT_ACCESS timestamps = [PRE_FORK_TIMESTAMP, POST_FORK_TIMESTAMP] expected_intrinsics = [expected_pre, expected_post] @@ -322,10 +324,10 @@ def test_access_list_floor_across_amsterdam_transition( post_costs.TX_DATA_TOKEN_STANDARD ) + calculate_access_list_floor_tokens(access_list) expected_post = int( - post_costs.TX_BASE - + post_costs.COLD_ACCOUNT_ACCESS - + post_tokens * post_costs.TX_DATA_TOKEN_FLOOR + post_costs.TX_BASE + post_tokens * post_costs.TX_DATA_TOKEN_FLOOR ) + if post_fork.is_eip_enabled(2780): + expected_post += int(post_costs.COLD_ACCOUNT_ACCESS) timestamps = [PRE_FORK_TIMESTAMP, POST_FORK_TIMESTAMP] expected_floors = [expected_pre, expected_post] diff --git a/tests/amsterdam/eip7997_deterministic_factory_predeploy/conftest.py b/tests/amsterdam/eip7997_deterministic_factory_predeploy/conftest.py new file mode 100644 index 00000000000..de93abfcfef --- /dev/null +++ b/tests/amsterdam/eip7997_deterministic_factory_predeploy/conftest.py @@ -0,0 +1,10 @@ +"""Pytest (plugin) definitions local to EIP-7997 tests.""" + +import pytest + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Mark all tests in this subdir as not valid for Monad forks.""" + metafunc.definition.add_marker( + pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) + ) diff --git a/tests/amsterdam/eip8024_dupn_swapn_exchange/conftest.py b/tests/amsterdam/eip8024_dupn_swapn_exchange/conftest.py new file mode 100644 index 00000000000..d8c781bb489 --- /dev/null +++ b/tests/amsterdam/eip8024_dupn_swapn_exchange/conftest.py @@ -0,0 +1,10 @@ +"""Pytest (plugin) definitions local to EIP-8024 tests.""" + +import pytest + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Mark all tests in this subdir as not valid for Monad forks.""" + metafunc.definition.add_marker( + pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) + ) diff --git a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/conftest.py b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/conftest.py new file mode 100644 index 00000000000..2d16c275db4 --- /dev/null +++ b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/conftest.py @@ -0,0 +1,10 @@ +"""Pytest (plugin) definitions local to EIP-8037 tests.""" + +import pytest + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Mark all tests in this subdir as not valid for Monad forks.""" + metafunc.definition.add_marker( + pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) + ) diff --git a/tests/amsterdam/eip8038_state_access_gas_cost_increase/conftest.py b/tests/amsterdam/eip8038_state_access_gas_cost_increase/conftest.py new file mode 100644 index 00000000000..cd29eb50dcb --- /dev/null +++ b/tests/amsterdam/eip8038_state_access_gas_cost_increase/conftest.py @@ -0,0 +1,10 @@ +"""Pytest (plugin) definitions local to EIP-8038 tests.""" + +import pytest + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Mark all tests in this subdir as not valid for Monad forks.""" + metafunc.definition.add_marker( + pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) + ) diff --git a/tests/amsterdam/eip8070_sparse_blobpool/conftest.py b/tests/amsterdam/eip8070_sparse_blobpool/conftest.py index cb2a757494c..dc3ee7c3ecd 100644 --- a/tests/amsterdam/eip8070_sparse_blobpool/conftest.py +++ b/tests/amsterdam/eip8070_sparse_blobpool/conftest.py @@ -148,3 +148,10 @@ def txs( ) txs.append(network_wrapped_tx) return txs + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Mark all tests in this subdir as not valid for Monad forks.""" + metafunc.definition.add_marker( + pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) + ) diff --git a/tests/amsterdam/eip8246_selfdestruct_no_burn/conftest.py b/tests/amsterdam/eip8246_selfdestruct_no_burn/conftest.py new file mode 100644 index 00000000000..4fb53dba402 --- /dev/null +++ b/tests/amsterdam/eip8246_selfdestruct_no_burn/conftest.py @@ -0,0 +1,10 @@ +"""Pytest (plugin) definitions local to EIP-8246 tests.""" + +import pytest + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Mark all tests in this subdir as not valid for Monad forks.""" + metafunc.definition.add_marker( + pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) + ) diff --git a/tests/amsterdam/eip8282_builder_execution_requests/conftest.py b/tests/amsterdam/eip8282_builder_execution_requests/conftest.py index e17cee467fc..7a42ba76785 100644 --- a/tests/amsterdam/eip8282_builder_execution_requests/conftest.py +++ b/tests/amsterdam/eip8282_builder_execution_requests/conftest.py @@ -1,8 +1,17 @@ """Fixtures for the EIP-8282 builder execution request tests.""" +import pytest + from ...common.system_contract_request_fixtures import ( blocks, # noqa: F401 included_requests, # noqa: F401 system_contract_interactions_per_block_copy, # noqa: F401 timestamp, # noqa: F401 ) + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Mark all tests in this subdir as not valid for Monad forks.""" + metafunc.definition.add_marker( + pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) + ) From d90d9f4e856c92b9024cca27d28616fa2e03bdfa Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:17:13 +0000 Subject: [PATCH 13/28] feat(monad_next): adopt EIP-7997 deterministic factory predeploy MONAD_NEXT carries the factory in its pre-allocation, ordered after Amsterdam by succession, and releases as the monad_eip7997 feature. Co-Authored-By: Claude --- .github/configs/feature.yaml | 4 ++++ .../pytest_commands/plugins/forks/forks.py | 4 +++- .../src/execution_testing/forks/base_fork.py | 20 +++++++++++++--- .../execution_testing/forks/forks/forks.py | 23 ++++++++++++++----- .../conftest.py | 10 ++++++++ .../eip7708_eth_transfer_logs/conftest.py | 10 ++++++++ .../conftest.py | 10 ++++++++ tests/amsterdam/eip7843_slotnum/conftest.py | 10 ++++++++ .../conftest.py | 10 ++++++++ .../conftest.py | 7 ++++++ .../test_factory.py | 12 ++++++++-- .../eip8024_dupn_swapn_exchange/conftest.py | 10 ++++++++ .../conftest.py | 10 ++++++++ .../conftest.py | 10 ++++++++ .../eip8070_sparse_blobpool/conftest.py | 7 ++++++ .../eip8246_selfdestruct_no_burn/conftest.py | 10 ++++++++ .../conftest.py | 9 ++++++++ 17 files changed, 164 insertions(+), 12 deletions(-) create mode 100644 tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/conftest.py create mode 100644 tests/amsterdam/eip7708_eth_transfer_logs/conftest.py create mode 100644 tests/amsterdam/eip7778_block_gas_accounting_without_refunds/conftest.py create mode 100644 tests/amsterdam/eip7843_slotnum/conftest.py create mode 100644 tests/amsterdam/eip7928_block_level_access_lists/conftest.py create mode 100644 tests/amsterdam/eip8024_dupn_swapn_exchange/conftest.py create mode 100644 tests/amsterdam/eip8037_state_creation_gas_cost_increase/conftest.py create mode 100644 tests/amsterdam/eip8038_state_access_gas_cost_increase/conftest.py create mode 100644 tests/amsterdam/eip8246_selfdestruct_no_burn/conftest.py diff --git a/.github/configs/feature.yaml b/.github/configs/feature.yaml index 7fca18fd9ef..ceb478c56b3 100644 --- a/.github/configs/feature.yaml +++ b/.github/configs/feature.yaml @@ -11,3 +11,7 @@ monad_runloop: evm-type: eels # Like `monad`, but `--monad-runloop` and eestnet chain id `30143` fill-params: -m blockchain_test --from=MONAD_EIGHT --until=MONAD_TEN --chain-id=30143 --monad-runloop -k "not invalid_header" + +monad_eip7997: + evm-type: eels + fill-params: -m blockchain_test --fork=MONAD_NEXT --chain-id=143 -k "not invalid_header" diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/forks.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/forks.py index 6075481806c..29ef4dc86e3 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/forks.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/forks.py @@ -1265,7 +1265,9 @@ def _process_with_marker_args( "Missing fork argument with 'valid_at_transition_to' marker." ) - if len(forks) > 1: + # A single EIP argument expands to one fork per enabling fork, so + # the limit is on the arguments rather than on the resolved forks. + if len(fork_args) > 1: raise Exception( "Too many forks specified to 'valid_at_transition_to' marker." ) diff --git a/packages/testing/src/execution_testing/forks/base_fork.py b/packages/testing/src/execution_testing/forks/base_fork.py index f842878c42f..808d94b6045 100644 --- a/packages/testing/src/execution_testing/forks/base_fork.py +++ b/packages/testing/src/execution_testing/forks/base_fork.py @@ -333,12 +333,23 @@ def _maybe_transitioned(fork_cls: "BaseForkMeta") -> "BaseForkMeta": @staticmethod def _is_subclass_of(a: "BaseForkMeta", b: "BaseForkMeta") -> bool: """ - Check if `a` is a subclass of `b`, taking fork transitions into - account. + Check if `a` is a subclass of `b`, taking fork transitions and + declared succession into account. + + A fork can follow another fork it does not inherit from, which + places it after that fork (and after everything that fork comes + after) in the fork order without adopting its behavior. """ a = BaseForkMeta._maybe_transitioned(a) b = BaseForkMeta._maybe_transitioned(b) - return issubclass(a, b) + if issubclass(a, b): + return True + followed = getattr(a, "_follows", None) + while followed is not None: + if issubclass(followed, b): + return True + followed = followed._follows + return False def __gt__(cls, other: "BaseForkMeta") -> bool: """Compare if a fork is newer than some other fork (cls > other).""" @@ -381,6 +392,7 @@ class BaseFork(ForkOpcodeInterface, metaclass=BaseForkMeta): _fork_by_timestamp: ClassVar[bool] = False _blob_constants: ClassVar[Dict[str, int]] = {} _deployed: ClassVar[bool] = True + _follows: ClassVar[Optional[Type["BaseFork"]]] = None _enabled_eips: ClassVar[Set[int]] = set() _enabling_forks: ClassVar[Set[Type["BaseFork"]]] = set() @@ -396,6 +408,7 @@ def __init_subclass__( transition_tool_name: Optional[str] = None, ignore: bool = False, bpo_fork: bool = False, + follows: Optional[Type["BaseFork"]] = None, ruleset_name: Optional[str] = None, fork_by_timestamp: Optional[bool] = None, deployed: Optional[bool] = None, @@ -410,6 +423,7 @@ def __init_subclass__( forks. """ cls._transition_tool_name = transition_tool_name + cls._follows = follows cls._ignore = ignore cls._bpo_fork = bpo_fork cls._ruleset_name = ruleset_name diff --git a/packages/testing/src/execution_testing/forks/forks/forks.py b/packages/testing/src/execution_testing/forks/forks/forks.py index ecf3b02f3ae..55e96539cd7 100644 --- a/packages/testing/src/execution_testing/forks/forks/forks.py +++ b/packages/testing/src/execution_testing/forks/forks/forks.py @@ -1805,12 +1805,6 @@ def _calculate_sstore_gas_mip8( return gas_cost -class MONAD_NEXT(MONAD_TEN): # noqa: N801 - """MONAD_NEXT fork, a placeholder identical to MONAD_TEN.""" - - pass - - class BPO1( Osaka, bpo_fork=True, @@ -1904,3 +1898,20 @@ def engine_payload_attribute_target_gas_limit(cls) -> bool: limit. """ return True + + +class MONAD_NEXT( # noqa: N801 + eips.EIP7997, + MONAD_TEN, + follows=Amsterdam, +): + """ + MONAD_NEXT fork. + + Amsterdam-based successor to MONAD_TEN, adopting the EIP-7997 + changes. The Amsterdam changes it does not adopt stay out of the + fork by not being inherited at all; the fork order still places + MONAD_NEXT after Amsterdam through `follows`. + """ + + pass diff --git a/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/conftest.py b/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/conftest.py new file mode 100644 index 00000000000..de52075d396 --- /dev/null +++ b/tests/amsterdam/eip2780_reduce_intrinsic_tx_gas/conftest.py @@ -0,0 +1,10 @@ +"""Pytest (plugin) definitions local to EIP-2780 tests.""" + +import pytest + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Mark all tests in this subdir as not valid for Monad forks.""" + metafunc.definition.add_marker( + pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) + ) diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/conftest.py b/tests/amsterdam/eip7708_eth_transfer_logs/conftest.py new file mode 100644 index 00000000000..fb1d9465b3e --- /dev/null +++ b/tests/amsterdam/eip7708_eth_transfer_logs/conftest.py @@ -0,0 +1,10 @@ +"""Pytest (plugin) definitions local to EIP-7708 tests.""" + +import pytest + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Mark all tests in this subdir as not valid for Monad forks.""" + metafunc.definition.add_marker( + pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) + ) diff --git a/tests/amsterdam/eip7778_block_gas_accounting_without_refunds/conftest.py b/tests/amsterdam/eip7778_block_gas_accounting_without_refunds/conftest.py new file mode 100644 index 00000000000..5cbdebdc9ef --- /dev/null +++ b/tests/amsterdam/eip7778_block_gas_accounting_without_refunds/conftest.py @@ -0,0 +1,10 @@ +"""Pytest (plugin) definitions local to EIP-7778 tests.""" + +import pytest + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Mark all tests in this subdir as not valid for Monad forks.""" + metafunc.definition.add_marker( + pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) + ) diff --git a/tests/amsterdam/eip7843_slotnum/conftest.py b/tests/amsterdam/eip7843_slotnum/conftest.py new file mode 100644 index 00000000000..6e43f18fb35 --- /dev/null +++ b/tests/amsterdam/eip7843_slotnum/conftest.py @@ -0,0 +1,10 @@ +"""Pytest (plugin) definitions local to EIP-7843 tests.""" + +import pytest + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Mark all tests in this subdir as not valid for Monad forks.""" + metafunc.definition.add_marker( + pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) + ) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/conftest.py b/tests/amsterdam/eip7928_block_level_access_lists/conftest.py new file mode 100644 index 00000000000..3bedcfe2651 --- /dev/null +++ b/tests/amsterdam/eip7928_block_level_access_lists/conftest.py @@ -0,0 +1,10 @@ +"""Pytest (plugin) definitions local to EIP-7928 tests.""" + +import pytest + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Mark all tests in this subdir as not valid for Monad forks.""" + metafunc.definition.add_marker( + pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) + ) diff --git a/tests/amsterdam/eip7976_increase_calldata_floor_cost/conftest.py b/tests/amsterdam/eip7976_increase_calldata_floor_cost/conftest.py index f92a0e003a0..9a252257297 100644 --- a/tests/amsterdam/eip7976_increase_calldata_floor_cost/conftest.py +++ b/tests/amsterdam/eip7976_increase_calldata_floor_cost/conftest.py @@ -370,3 +370,10 @@ def tx( blob_versioned_hashes=blob_versioned_hashes, error=tx_error, ) + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Mark all tests in this subdir as not valid for Monad forks.""" + metafunc.definition.add_marker( + pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) + ) diff --git a/tests/amsterdam/eip7997_deterministic_factory_predeploy/test_factory.py b/tests/amsterdam/eip7997_deterministic_factory_predeploy/test_factory.py index bd4f9c3031d..9bb208616c2 100644 --- a/tests/amsterdam/eip7997_deterministic_factory_predeploy/test_factory.py +++ b/tests/amsterdam/eip7997_deterministic_factory_predeploy/test_factory.py @@ -30,6 +30,7 @@ compute_create2_address, keccak256, ) +from execution_testing.forks import MONAD_EIGHT from ...prague.eip7702_set_code_tx.spec import Spec as Spec7702 from .spec import Spec, ref_spec_7997 @@ -538,6 +539,7 @@ def test_factory_receives_balance_via_selfdestruct( def test_factory_via_eip7702_delegation( state_test: StateTestFiller, pre: Alloc, + fork: Fork, ) -> None: """ An EOA delegates its code to the factory via an EIP-7702 @@ -549,6 +551,10 @@ def test_factory_via_eip7702_delegation( auth_signer = pre.fund_eoa() auth_signer_nonce = auth_signer.nonce + # Monad forbids the `CREATE2` of a delegated account's context, so + # the factory reverts there instead of deploying. + factory_reverts = fork >= MONAD_EIGHT + salt = 0x42 runtime_code = Op.PUSH1(0x01) + Op.PUSH1(0x00) + Op.RETURN initcode = Initcode(deploy_code=runtime_code) @@ -586,10 +592,12 @@ def test_factory_via_eip7702_delegation( ), post={ auth_signer: Account( - nonce=auth_signer_nonce + 2, + nonce=auth_signer_nonce + (1 if factory_reverts else 2), code=Spec7702.delegation_designation(Address(FACTORY)), ), - expected_address: Account(nonce=1, code=bytes(runtime_code)), + expected_address: Account.NONEXISTENT + if factory_reverts + else Account(nonce=1, code=bytes(runtime_code)), FACTORY: Account( nonce=1, balance=0, diff --git a/tests/amsterdam/eip8024_dupn_swapn_exchange/conftest.py b/tests/amsterdam/eip8024_dupn_swapn_exchange/conftest.py new file mode 100644 index 00000000000..d8c781bb489 --- /dev/null +++ b/tests/amsterdam/eip8024_dupn_swapn_exchange/conftest.py @@ -0,0 +1,10 @@ +"""Pytest (plugin) definitions local to EIP-8024 tests.""" + +import pytest + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Mark all tests in this subdir as not valid for Monad forks.""" + metafunc.definition.add_marker( + pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) + ) diff --git a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/conftest.py b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/conftest.py new file mode 100644 index 00000000000..2d16c275db4 --- /dev/null +++ b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/conftest.py @@ -0,0 +1,10 @@ +"""Pytest (plugin) definitions local to EIP-8037 tests.""" + +import pytest + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Mark all tests in this subdir as not valid for Monad forks.""" + metafunc.definition.add_marker( + pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) + ) diff --git a/tests/amsterdam/eip8038_state_access_gas_cost_increase/conftest.py b/tests/amsterdam/eip8038_state_access_gas_cost_increase/conftest.py new file mode 100644 index 00000000000..cd29eb50dcb --- /dev/null +++ b/tests/amsterdam/eip8038_state_access_gas_cost_increase/conftest.py @@ -0,0 +1,10 @@ +"""Pytest (plugin) definitions local to EIP-8038 tests.""" + +import pytest + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Mark all tests in this subdir as not valid for Monad forks.""" + metafunc.definition.add_marker( + pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) + ) diff --git a/tests/amsterdam/eip8070_sparse_blobpool/conftest.py b/tests/amsterdam/eip8070_sparse_blobpool/conftest.py index cb2a757494c..dc3ee7c3ecd 100644 --- a/tests/amsterdam/eip8070_sparse_blobpool/conftest.py +++ b/tests/amsterdam/eip8070_sparse_blobpool/conftest.py @@ -148,3 +148,10 @@ def txs( ) txs.append(network_wrapped_tx) return txs + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Mark all tests in this subdir as not valid for Monad forks.""" + metafunc.definition.add_marker( + pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) + ) diff --git a/tests/amsterdam/eip8246_selfdestruct_no_burn/conftest.py b/tests/amsterdam/eip8246_selfdestruct_no_burn/conftest.py new file mode 100644 index 00000000000..4fb53dba402 --- /dev/null +++ b/tests/amsterdam/eip8246_selfdestruct_no_burn/conftest.py @@ -0,0 +1,10 @@ +"""Pytest (plugin) definitions local to EIP-8246 tests.""" + +import pytest + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Mark all tests in this subdir as not valid for Monad forks.""" + metafunc.definition.add_marker( + pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) + ) diff --git a/tests/amsterdam/eip8282_builder_execution_requests/conftest.py b/tests/amsterdam/eip8282_builder_execution_requests/conftest.py index e17cee467fc..7a42ba76785 100644 --- a/tests/amsterdam/eip8282_builder_execution_requests/conftest.py +++ b/tests/amsterdam/eip8282_builder_execution_requests/conftest.py @@ -1,8 +1,17 @@ """Fixtures for the EIP-8282 builder execution request tests.""" +import pytest + from ...common.system_contract_request_fixtures import ( blocks, # noqa: F401 included_requests, # noqa: F401 system_contract_interactions_per_block_copy, # noqa: F401 timestamp, # noqa: F401 ) + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Mark all tests in this subdir as not valid for Monad forks.""" + metafunc.definition.add_marker( + pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) + ) From bc45cc4ea190dc8a444327d47c98b83e2354688d Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Tue, 25 Aug 2026 10:49:27 +0000 Subject: [PATCH 14/28] refactor(forks): declare fork succession as a trait, not a keyword `follows()` sits with the other fork traits, so `__init_subclass__` is untouched and a successor fork inherits the order without restating it. Co-Authored-By: Claude --- .../src/execution_testing/forks/base_fork.py | 20 ++++++++++++++----- .../execution_testing/forks/forks/forks.py | 6 ++++-- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/packages/testing/src/execution_testing/forks/base_fork.py b/packages/testing/src/execution_testing/forks/base_fork.py index 808d94b6045..38dea7ca65c 100644 --- a/packages/testing/src/execution_testing/forks/base_fork.py +++ b/packages/testing/src/execution_testing/forks/base_fork.py @@ -344,11 +344,13 @@ def _is_subclass_of(a: "BaseForkMeta", b: "BaseForkMeta") -> bool: b = BaseForkMeta._maybe_transitioned(b) if issubclass(a, b): return True - followed = getattr(a, "_follows", None) + # The metaclass sees its instances as plain classes, so the + # trait is reached through a cast, as elsewhere in this class. + followed = cast(Type["BaseFork"], a).follows() while followed is not None: if issubclass(followed, b): return True - followed = followed._follows + followed = followed.follows() return False def __gt__(cls, other: "BaseForkMeta") -> bool: @@ -392,7 +394,6 @@ class BaseFork(ForkOpcodeInterface, metaclass=BaseForkMeta): _fork_by_timestamp: ClassVar[bool] = False _blob_constants: ClassVar[Dict[str, int]] = {} _deployed: ClassVar[bool] = True - _follows: ClassVar[Optional[Type["BaseFork"]]] = None _enabled_eips: ClassVar[Set[int]] = set() _enabling_forks: ClassVar[Set[Type["BaseFork"]]] = set() @@ -408,7 +409,6 @@ def __init_subclass__( transition_tool_name: Optional[str] = None, ignore: bool = False, bpo_fork: bool = False, - follows: Optional[Type["BaseFork"]] = None, ruleset_name: Optional[str] = None, fork_by_timestamp: Optional[bool] = None, deployed: Optional[bool] = None, @@ -423,7 +423,6 @@ def __init_subclass__( forks. """ cls._transition_tool_name = transition_tool_name - cls._follows = follows cls._ignore = ignore cls._bpo_fork = bpo_fork cls._ruleset_name = ruleset_name @@ -1421,6 +1420,17 @@ def enabling_forks(cls) -> Set[Type["BaseFork"]]: raise Exception(f"Class {cls.__name__} is not an EIP.") return cls._enabling_forks + @classmethod + def follows(cls) -> Type["BaseFork"] | None: + """ + Return the fork this one comes after without inheriting it. + + A fork that reuses another lineage's ordering overrides this; + comparisons then place it after that fork, and after everything + that fork comes after, while its behavior stays its own. + """ + return None + @classmethod def parent(cls) -> Type["BaseFork"] | None: """Return the parent fork.""" diff --git a/packages/testing/src/execution_testing/forks/forks/forks.py b/packages/testing/src/execution_testing/forks/forks/forks.py index 618ee8a0000..52c47fcb45b 100644 --- a/packages/testing/src/execution_testing/forks/forks/forks.py +++ b/packages/testing/src/execution_testing/forks/forks/forks.py @@ -1903,7 +1903,6 @@ def engine_payload_attribute_target_gas_limit(cls) -> bool: class MONAD_NEXT( # noqa: N801 eips.EIP8246, MONAD_TEN, - follows=Amsterdam, ): """ MONAD_NEXT fork. @@ -1914,4 +1913,7 @@ class MONAD_NEXT( # noqa: N801 MONAD_NEXT after Amsterdam through `follows`. """ - pass + @classmethod + def follows(cls) -> type[BaseFork] | None: + """MONAD_NEXT comes after Amsterdam without inheriting it.""" + return Amsterdam From 4909497420c98e20708d6f610f727df209181c82 Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Tue, 25 Aug 2026 10:50:59 +0000 Subject: [PATCH 15/28] refactor(forks): declare fork succession as a trait, not a keyword `follows()` sits with the other fork traits, so `__init_subclass__` is untouched and a successor fork inherits the order without restating it. Co-Authored-By: Claude --- .../src/execution_testing/forks/base_fork.py | 20 ++++++++++++++----- .../execution_testing/forks/forks/forks.py | 6 +++++- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/packages/testing/src/execution_testing/forks/base_fork.py b/packages/testing/src/execution_testing/forks/base_fork.py index 4767d361e35..30d58136385 100644 --- a/packages/testing/src/execution_testing/forks/base_fork.py +++ b/packages/testing/src/execution_testing/forks/base_fork.py @@ -344,11 +344,13 @@ def _is_subclass_of(a: "BaseForkMeta", b: "BaseForkMeta") -> bool: b = BaseForkMeta._maybe_transitioned(b) if issubclass(a, b): return True - followed = getattr(a, "_follows", None) + # The metaclass sees its instances as plain classes, so the + # trait is reached through a cast, as elsewhere in this class. + followed = cast(Type["BaseFork"], a).follows() while followed is not None: if issubclass(followed, b): return True - followed = followed._follows + followed = followed.follows() return False def __gt__(cls, other: "BaseForkMeta") -> bool: @@ -392,7 +394,6 @@ class BaseFork(ForkOpcodeInterface, metaclass=BaseForkMeta): _fork_by_timestamp: ClassVar[bool] = False _blob_constants: ClassVar[Dict[str, int]] = {} _deployed: ClassVar[bool] = True - _follows: ClassVar[Optional[Type["BaseFork"]]] = None _enabled_eips: ClassVar[Set[int]] = set() _enabling_forks: ClassVar[Set[Type["BaseFork"]]] = set() @@ -408,7 +409,6 @@ def __init_subclass__( transition_tool_name: Optional[str] = None, ignore: bool = False, bpo_fork: bool = False, - follows: Optional[Type["BaseFork"]] = None, ruleset_name: Optional[str] = None, fork_by_timestamp: Optional[bool] = None, deployed: Optional[bool] = None, @@ -423,7 +423,6 @@ def __init_subclass__( forks. """ cls._transition_tool_name = transition_tool_name - cls._follows = follows cls._ignore = ignore cls._bpo_fork = bpo_fork cls._ruleset_name = ruleset_name @@ -1433,6 +1432,17 @@ def enabling_forks(cls) -> Set[Type["BaseFork"]]: raise Exception(f"Class {cls.__name__} is not an EIP.") return cls._enabling_forks + @classmethod + def follows(cls) -> Type["BaseFork"] | None: + """ + Return the fork this one comes after without inheriting it. + + A fork that reuses another lineage's ordering overrides this; + comparisons then place it after that fork, and after everything + that fork comes after, while its behavior stays its own. + """ + return None + @classmethod def parent(cls) -> Type["BaseFork"] | None: """Return the parent fork.""" diff --git a/packages/testing/src/execution_testing/forks/forks/forks.py b/packages/testing/src/execution_testing/forks/forks/forks.py index e3e4ffe4932..974cd31b395 100644 --- a/packages/testing/src/execution_testing/forks/forks/forks.py +++ b/packages/testing/src/execution_testing/forks/forks/forks.py @@ -1910,7 +1910,6 @@ class MONAD_NEXT( # noqa: N801 eips.EIP7843, eips.EIP8024, MONAD_TEN, - follows=Amsterdam, ): """ MONAD_NEXT fork. @@ -1921,6 +1920,11 @@ class MONAD_NEXT( # noqa: N801 order still places MONAD_NEXT after Amsterdam through `follows`. """ + @classmethod + def follows(cls) -> type[BaseFork] | None: + """MONAD_NEXT comes after Amsterdam without inheriting it.""" + return Amsterdam + @classmethod def header_bal_hash_required(cls) -> bool: """ From 1faef38e4baab1bd12fc049f4334962c7fba4b41 Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Tue, 25 Aug 2026 10:51:08 +0000 Subject: [PATCH 16/28] refactor(forks): declare fork succession as a trait, not a keyword `follows()` sits with the other fork traits, so `__init_subclass__` is untouched and a successor fork inherits the order without restating it. Co-Authored-By: Claude --- .../src/execution_testing/forks/base_fork.py | 20 ++++++++++++++----- .../execution_testing/forks/forks/forks.py | 6 +++++- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/packages/testing/src/execution_testing/forks/base_fork.py b/packages/testing/src/execution_testing/forks/base_fork.py index 808d94b6045..38dea7ca65c 100644 --- a/packages/testing/src/execution_testing/forks/base_fork.py +++ b/packages/testing/src/execution_testing/forks/base_fork.py @@ -344,11 +344,13 @@ def _is_subclass_of(a: "BaseForkMeta", b: "BaseForkMeta") -> bool: b = BaseForkMeta._maybe_transitioned(b) if issubclass(a, b): return True - followed = getattr(a, "_follows", None) + # The metaclass sees its instances as plain classes, so the + # trait is reached through a cast, as elsewhere in this class. + followed = cast(Type["BaseFork"], a).follows() while followed is not None: if issubclass(followed, b): return True - followed = followed._follows + followed = followed.follows() return False def __gt__(cls, other: "BaseForkMeta") -> bool: @@ -392,7 +394,6 @@ class BaseFork(ForkOpcodeInterface, metaclass=BaseForkMeta): _fork_by_timestamp: ClassVar[bool] = False _blob_constants: ClassVar[Dict[str, int]] = {} _deployed: ClassVar[bool] = True - _follows: ClassVar[Optional[Type["BaseFork"]]] = None _enabled_eips: ClassVar[Set[int]] = set() _enabling_forks: ClassVar[Set[Type["BaseFork"]]] = set() @@ -408,7 +409,6 @@ def __init_subclass__( transition_tool_name: Optional[str] = None, ignore: bool = False, bpo_fork: bool = False, - follows: Optional[Type["BaseFork"]] = None, ruleset_name: Optional[str] = None, fork_by_timestamp: Optional[bool] = None, deployed: Optional[bool] = None, @@ -423,7 +423,6 @@ def __init_subclass__( forks. """ cls._transition_tool_name = transition_tool_name - cls._follows = follows cls._ignore = ignore cls._bpo_fork = bpo_fork cls._ruleset_name = ruleset_name @@ -1421,6 +1420,17 @@ def enabling_forks(cls) -> Set[Type["BaseFork"]]: raise Exception(f"Class {cls.__name__} is not an EIP.") return cls._enabling_forks + @classmethod + def follows(cls) -> Type["BaseFork"] | None: + """ + Return the fork this one comes after without inheriting it. + + A fork that reuses another lineage's ordering overrides this; + comparisons then place it after that fork, and after everything + that fork comes after, while its behavior stays its own. + """ + return None + @classmethod def parent(cls) -> Type["BaseFork"] | None: """Return the parent fork.""" diff --git a/packages/testing/src/execution_testing/forks/forks/forks.py b/packages/testing/src/execution_testing/forks/forks/forks.py index 04fde59cc0c..a4a8218052b 100644 --- a/packages/testing/src/execution_testing/forks/forks/forks.py +++ b/packages/testing/src/execution_testing/forks/forks/forks.py @@ -1903,7 +1903,6 @@ def engine_payload_attribute_target_gas_limit(cls) -> bool: class MONAD_NEXT( # noqa: N801 eips.EIP7981, MONAD_TEN, - follows=Amsterdam, ): """ MONAD_NEXT fork. @@ -1914,4 +1913,9 @@ class MONAD_NEXT( # noqa: N801 MONAD_NEXT after Amsterdam through `follows`. """ + @classmethod + def follows(cls) -> type[BaseFork] | None: + """MONAD_NEXT comes after Amsterdam without inheriting it.""" + return Amsterdam + pass From 7916fb731a5a9020489a7536733ae275cac93103 Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Tue, 25 Aug 2026 10:51:08 +0000 Subject: [PATCH 17/28] refactor(forks): declare fork succession as a trait, not a keyword `follows()` sits with the other fork traits, so `__init_subclass__` is untouched and a successor fork inherits the order without restating it. Co-Authored-By: Claude --- .../src/execution_testing/forks/base_fork.py | 20 ++++++++++++++----- .../execution_testing/forks/forks/forks.py | 6 +++++- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/packages/testing/src/execution_testing/forks/base_fork.py b/packages/testing/src/execution_testing/forks/base_fork.py index 808d94b6045..38dea7ca65c 100644 --- a/packages/testing/src/execution_testing/forks/base_fork.py +++ b/packages/testing/src/execution_testing/forks/base_fork.py @@ -344,11 +344,13 @@ def _is_subclass_of(a: "BaseForkMeta", b: "BaseForkMeta") -> bool: b = BaseForkMeta._maybe_transitioned(b) if issubclass(a, b): return True - followed = getattr(a, "_follows", None) + # The metaclass sees its instances as plain classes, so the + # trait is reached through a cast, as elsewhere in this class. + followed = cast(Type["BaseFork"], a).follows() while followed is not None: if issubclass(followed, b): return True - followed = followed._follows + followed = followed.follows() return False def __gt__(cls, other: "BaseForkMeta") -> bool: @@ -392,7 +394,6 @@ class BaseFork(ForkOpcodeInterface, metaclass=BaseForkMeta): _fork_by_timestamp: ClassVar[bool] = False _blob_constants: ClassVar[Dict[str, int]] = {} _deployed: ClassVar[bool] = True - _follows: ClassVar[Optional[Type["BaseFork"]]] = None _enabled_eips: ClassVar[Set[int]] = set() _enabling_forks: ClassVar[Set[Type["BaseFork"]]] = set() @@ -408,7 +409,6 @@ def __init_subclass__( transition_tool_name: Optional[str] = None, ignore: bool = False, bpo_fork: bool = False, - follows: Optional[Type["BaseFork"]] = None, ruleset_name: Optional[str] = None, fork_by_timestamp: Optional[bool] = None, deployed: Optional[bool] = None, @@ -423,7 +423,6 @@ def __init_subclass__( forks. """ cls._transition_tool_name = transition_tool_name - cls._follows = follows cls._ignore = ignore cls._bpo_fork = bpo_fork cls._ruleset_name = ruleset_name @@ -1421,6 +1420,17 @@ def enabling_forks(cls) -> Set[Type["BaseFork"]]: raise Exception(f"Class {cls.__name__} is not an EIP.") return cls._enabling_forks + @classmethod + def follows(cls) -> Type["BaseFork"] | None: + """ + Return the fork this one comes after without inheriting it. + + A fork that reuses another lineage's ordering overrides this; + comparisons then place it after that fork, and after everything + that fork comes after, while its behavior stays its own. + """ + return None + @classmethod def parent(cls) -> Type["BaseFork"] | None: """Return the parent fork.""" diff --git a/packages/testing/src/execution_testing/forks/forks/forks.py b/packages/testing/src/execution_testing/forks/forks/forks.py index 55e96539cd7..10e722b2f37 100644 --- a/packages/testing/src/execution_testing/forks/forks/forks.py +++ b/packages/testing/src/execution_testing/forks/forks/forks.py @@ -1903,7 +1903,6 @@ def engine_payload_attribute_target_gas_limit(cls) -> bool: class MONAD_NEXT( # noqa: N801 eips.EIP7997, MONAD_TEN, - follows=Amsterdam, ): """ MONAD_NEXT fork. @@ -1914,4 +1913,9 @@ class MONAD_NEXT( # noqa: N801 MONAD_NEXT after Amsterdam through `follows`. """ + @classmethod + def follows(cls) -> type[BaseFork] | None: + """MONAD_NEXT comes after Amsterdam without inheriting it.""" + return Amsterdam + pass From cfa37f31a6a9f76e52dbe9a20b0285a5a121ff36 Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Tue, 25 Aug 2026 10:52:20 +0000 Subject: [PATCH 18/28] refactor(forks): derive block access list support from the EIP A fork builds the lists when EIP-7928 is enabled, so the trait needs no abstract declaration, no genesis default and no mixin override. Co-Authored-By: Claude --- .../testing/src/execution_testing/forks/base_fork.py | 9 ++++----- .../forks/forks/eips/amsterdam/eip_7928.py | 7 ------- .../testing/src/execution_testing/forks/forks/forks.py | 5 ----- 3 files changed, 4 insertions(+), 17 deletions(-) diff --git a/packages/testing/src/execution_testing/forks/base_fork.py b/packages/testing/src/execution_testing/forks/base_fork.py index 30d58136385..21801a25162 100644 --- a/packages/testing/src/execution_testing/forks/base_fork.py +++ b/packages/testing/src/execution_testing/forks/base_fork.py @@ -577,16 +577,15 @@ def header_bal_hash_required(cls) -> bool: pass @classmethod - @abstractmethod def supports_block_access_lists(cls) -> bool: """ Return true if the fork builds block access lists (EIP-7928). - A fork can require the block access list hash header field without - building block access lists (e.g. Monad); the field is then fixed - at zero. + A fork can require the block access list hash header field + without building the lists, and then fixes the field at zero, so + this follows the EIP rather than the header requirement. """ - pass + return cls.is_eip_enabled(7928) @classmethod @abstractmethod diff --git a/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_7928.py b/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_7928.py index d7c787eed92..1eacd844b21 100644 --- a/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_7928.py +++ b/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_7928.py @@ -29,13 +29,6 @@ def header_bal_hash_required(cls) -> bool: """ return True - @classmethod - def supports_block_access_lists(cls) -> bool: - """ - From EIP-7928, blocks build block access lists. - """ - return True - @classmethod def gas_costs(cls) -> GasCosts: """ diff --git a/packages/testing/src/execution_testing/forks/forks/forks.py b/packages/testing/src/execution_testing/forks/forks/forks.py index 974cd31b395..348901ef328 100644 --- a/packages/testing/src/execution_testing/forks/forks/forks.py +++ b/packages/testing/src/execution_testing/forks/forks/forks.py @@ -954,11 +954,6 @@ def header_bal_hash_required(cls) -> bool: """At genesis, header must not contain block access list hash.""" return False - @classmethod - def supports_block_access_lists(cls) -> bool: - """At genesis, no block access lists are built.""" - return False - @classmethod def empty_block_bal_item_count(cls) -> int: """Pre-Amsterdam forks have no block access list.""" From 1a09d2443470df05dd84fac8108c02c1f748b61e Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:08:25 +0000 Subject: [PATCH 19/28] feat(ci): rehearse the monad fixture releases before they are cut Collect and fill each feature on PRs, `from-upstream` and `eips/**`. Co-Authored-By: Claude --- .github/actions/build-fixtures/action.yaml | 6 +- .github/configs/feature.yaml | 5 - .github/scripts/check_release_matrix.py | 124 +++++++++++++++ .github/workflows/check_release.yaml | 174 +++++++++++++++++++++ 4 files changed, 303 insertions(+), 6 deletions(-) create mode 100755 .github/scripts/check_release_matrix.py create mode 100644 .github/workflows/check_release.yaml diff --git a/.github/actions/build-fixtures/action.yaml b/.github/actions/build-fixtures/action.yaml index 36fff598e02..98148e97246 100644 --- a/.github/actions/build-fixtures/action.yaml +++ b/.github/actions/build-fixtures/action.yaml @@ -25,6 +25,9 @@ inputs: evm_ref: description: "Override the t8n tool branch / tag / commit" default: "" + upload: + description: "Upload the filled fixtures. Set to false to rehearse a fill and discard its output." + default: "true" runs: using: "composite" steps: @@ -77,11 +80,12 @@ runs: exit "$EXIT_CODE" fi - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + if: inputs.upload == 'true' with: name: fixtures_${{ inputs.release_name }} path: fixtures_${{ inputs.release_name }}.tar.gz - name: Upload fixture directory (split) - if: inputs.split_label != '' + if: inputs.upload == 'true' && inputs.split_label != '' uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: fixtures__${{ inputs.split_label }} diff --git a/.github/configs/feature.yaml b/.github/configs/feature.yaml index 36ab811fdb9..91eed36ca8a 100644 --- a/.github/configs/feature.yaml +++ b/.github/configs/feature.yaml @@ -1,8 +1,3 @@ -# Unless filling for special features, all features should fill for previous forks (starting from Frontier) too -mainnet: - evm-type: eels - fill-params: --until=BPO4 --generate-all-formats - monad: evm-type: eels fill-params: -m blockchain_test --from=MONAD_EIGHT --until=MONAD_TEN --chain-id=143 -k "not invalid_header" diff --git a/.github/scripts/check_release_matrix.py b/.github/scripts/check_release_matrix.py new file mode 100755 index 00000000000..556d5b92bf7 --- /dev/null +++ b/.github/scripts/check_release_matrix.py @@ -0,0 +1,124 @@ +#!/usr/bin/env -S uv run --script +# +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "pyyaml", +# ] +# /// +""" +Build the job matrix for a fixture release rehearsal. + +Usage: `check_release_matrix.py [features] [branch]`, where `features` +is an optional comma- or space-separated subset of the feature names in +`.github/configs/feature.yaml`. + +With no `features`, an EIP branch rehearses the feature it releases for +its own EIP and nothing else, and every other branch rehearses every +feature. Either way a branch that adds a feature is covered without +touching the workflow. + +Reuse `generate_build_matrix.py` so a rehearsal fills exactly what the +release fills, then flatten the per-feature matrices into the single +`fill_matrix` a `strategy.matrix` consumes. +""" + +import json +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) + +from generate_build_matrix import ( # noqa: E402 + FEATURE_CONFIG, + FORK_RANGES_CONFIG, + build_matrix, + fail, + load_config, +) + +# EIP branches follow `eips//eip-`, with `+` joining the EIP +# numbers a combined branch carries, e.g. `eips/amsterdam/eip-2345+3456`. +EIP_BRANCH_RE = re.compile(r"^eips/[^/]+/eip-([0-9]+(?:\+[0-9]+)*)$") + + +def eip_features(defined: list[str], branch: str) -> list[str]: + """ + Return the features *branch* releases for its own EIPs. + + An EIP branch names its feature after the EIPs it carries, so + `eips/monad_next/eip-7997` releases `monad_eip7997` and + `eips/amsterdam/eip-2345+3456` releases `monad_eip2345+3456`. The + numbers must match in full: a combined branch does not claim the + feature of either EIP on its own, and neither claims the combined + one. Return an empty list for any other branch, and for an EIP + branch that has not declared a feature of its own yet. + """ + match = EIP_BRANCH_RE.match(branch) + if not match: + return [] + numbers = re.compile(rf"eip{re.escape(match.group(1))}(?![0-9+])") + return [name for name in defined if numbers.search(name)] + + +def defined_features(config: dict) -> list[str]: + """Return every feature name in `feature.yaml`, in config order.""" + return [ + name for name, feature in config.items() if isinstance(feature, dict) + ] + + +def select_features(config: dict, requested: str, branch: str) -> list[str]: + """ + Narrow the rehearsal to the requested features. + + An empty request falls back to the features *branch* releases for + its own EIPs, then to every feature: this fork releases all of + them. An unknown name fails the run rather than silently + rehearsing less than was asked for. + """ + defined = defined_features(config) + names = [name for name in requested.replace(",", " ").split() if name] + if names: + unknown = [name for name in names if name not in defined] + if unknown: + fail( + f"unknown feature(s) {', '.join(unknown)}; " + f"{FEATURE_CONFIG} defines {', '.join(defined)}" + ) + return names + from_branch = eip_features(defined, branch) + if from_branch: + print( + f"Branch '{branch}' releases {', '.join(from_branch)}; " + "rehearsing only that.", + file=sys.stderr, + ) + return from_branch + return defined + + +def main() -> None: + """Print the rehearsal's feature list and fill matrix to stdout.""" + requested = sys.argv[1] if len(sys.argv) > 1 else "" + branch = sys.argv[2] if len(sys.argv) > 2 else "" + + config = load_config(FEATURE_CONFIG) + fork_ranges = load_config(FORK_RANGES_CONFIG) or [] + + features = select_features(config, requested, branch) + if not features: + fail(f"{FEATURE_CONFIG} defines no feature") + + matrix: list[dict] = [] + for name in features: + entries, _ = build_matrix(config[name], name, fork_ranges) + matrix.extend(entries) + + print(f"features={json.dumps(features)}") + print(f"fill_matrix={json.dumps(matrix)}") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/check_release.yaml b/.github/workflows/check_release.yaml new file mode 100644 index 00000000000..8b1f7447b6f --- /dev/null +++ b/.github/workflows/check_release.yaml @@ -0,0 +1,174 @@ +name: Check Fixture Release + +run-name: ${{ github.event_name == 'workflow_dispatch' && format('Check Fixture Release ({0}) {1}', inputs.depth, inputs.features) || 'Check Fixture Release' }} + +# Rehearse the fixture releases this fork ships, so a fill that a change +# breaks surfaces on the branch that broke it rather than on the next +# release attempt. +# +# Two tiers, both running on every pull request and on pushes to the +# branches that release in parallel with the fork branch, +# `from-upstream` and `eips/**`. +# +# `collect` is cheap: it reads the features the branch releases out of +# `.github/configs/feature.yaml`, builds the release's job matrix +# from it and collects its tests, catching malformed feature entries, +# import errors and parametrization errors in a few minutes. It gates +# `fill`, which is comprehensive: `fill` runs the very fill the release +# runs, via the release's own action, and is the only tier that catches +# a test that fills wrong. A manual dispatch can ask for `collect` +# alone, and can narrow the run to a subset of the features. + +on: + push: + branches: + - from-upstream + - "eips/**" + paths-ignore: + - "**.md" + - "LICENSE*" + - ".gitignore" + - ".vscode/**" + - "whitelist.txt" + - "docs/**" + - "mkdocs.yml" + pull_request: + paths-ignore: + - "**.md" + - "LICENSE*" + - ".gitignore" + - ".vscode/**" + - "whitelist.txt" + - "docs/**" + - "mkdocs.yml" + workflow_dispatch: + inputs: + depth: + description: "full = fill every feature (hours); collect = validate the config and collect the tests (minutes)" + required: true + type: choice + options: [full, collect] + default: full + features: + description: "Features to rehearse, e.g. monad_runloop. Empty = an EIP branch's own feature, or every feature elsewhere." + required: false + type: string + +concurrency: + group: ${{ github.workflow }}-${{ github.ref || github.run_id }} + cancel-in-progress: ${{ github.ref_name != github.event.repository.default_branch }} + +permissions: + contents: read + +jobs: + setup: + runs-on: ubuntu-latest + outputs: + features: ${{ steps.matrix.outputs.features }} + fill_matrix: ${{ steps.matrix.outputs.fill_matrix }} + fill: ${{ steps.depth.outputs.fill }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + submodules: false + - uses: ./.github/actions/setup-uv + + - name: Select the features to rehearse + id: matrix + shell: bash + env: + INPUT_FEATURES: ${{ inputs.features }} + # The head branch, so a pull request from an EIP branch + # rehearses that branch's feature; `ref_name` is the merge ref + # on a pull request and the branch everywhere else. + BRANCH: ${{ github.head_ref || github.ref_name }} + run: | + # The feature selection and the per-feature release matrix live + # in (and are shared with the release workflow via) + # check_release_matrix.py. + uv run -q .github/scripts/check_release_matrix.py \ + "$INPUT_FEATURES" "$BRANCH" | tee -a "$GITHUB_OUTPUT" + + - name: Decide whether to fill + id: depth + shell: bash + env: + EVENT: ${{ github.event_name }} + DEPTH: ${{ inputs.depth }} + run: | + # Every automatic trigger fills; only a dispatch can ask for + # the cheap tier alone. + if [ "$EVENT" = "workflow_dispatch" ] && [ "$DEPTH" != "full" ] + then + fill=false + else + fill=true + fi + echo "Fill: $fill" + echo "fill=$fill" >> "$GITHUB_OUTPUT" + + collect: + name: collect (${{ matrix.feature }}) + needs: setup + runs-on: ubuntu-24.04 + timeout-minutes: 30 + strategy: + # A rehearsal wants every feature's verdict, not the first failure. + fail-fast: false + matrix: + feature: ${{ fromJson(needs.setup.outputs.features) }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + submodules: true + - uses: ./.github/actions/setup-uv + - name: Install EEST + run: uv sync --no-progress + + - name: Extract fixture release properties from config + id: properties + run: | + uv run -q .github/scripts/get_release_props.py ${{ matrix.feature }} >> "$GITHUB_OUTPUT" + + - name: Collect the release's tests + shell: bash + run: | + # `fill-params` is interpolated rather than passed through a + # variable so its quoting survives, as in `build-fixtures`. + # Collecting nothing means an empty release, so pytest's exit + # code 5 fails here where a release's fork-range split + # tolerates it. + status=0 + uv run fill --collect-only -q \ + ${{ steps.properties.outputs.fill-params }} > collected.txt \ + || status=$? + tail -n 20 collected.txt + exit "$status" + + fill: + name: fill (${{ matrix.label || matrix.feature }}) + needs: [setup, collect] + if: needs.setup.outputs.fill == 'true' + runs-on: ubuntu-24.04 + # Hosted runners cap a job at six hours regardless; the explicit + # timeout keeps a wedged fill from burning the whole budget. + timeout-minutes: 360 + strategy: + fail-fast: false + matrix: + include: ${{ fromJson(needs.setup.outputs.fill_matrix) }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + submodules: true + + - uses: ./.github/actions/build-fixtures + with: + release_name: ${{ matrix.feature }} + from_fork: ${{ matrix.from_fork }} + until_fork: ${{ matrix.until_fork }} + split_label: ${{ matrix.label }} + # A rehearsal only reports whether the fill succeeds; keeping + # its fixtures would cost storage for output nothing consumes. + upload: "false" From 74a642d65853689704726d46c5f82fee9b33d6e7 Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:48:10 +0000 Subject: [PATCH 20/28] test(amsterdam): select the suites for the EIPs MONAD_NEXT now adopts The merged fork adopts EIP-7708, EIP-7843, EIP-7997, EIP-8024 and EIP-8246, so their suites lose the Monad exclusion; the EIP-7976 hook lands once. Co-Authored-By: Claude --- tests/amsterdam/eip7708_eth_transfer_logs/conftest.py | 10 ---------- tests/amsterdam/eip7843_slotnum/conftest.py | 10 ---------- .../eip7976_increase_calldata_floor_cost/conftest.py | 7 ------- .../conftest.py | 10 ---------- .../amsterdam/eip8024_dupn_swapn_exchange/conftest.py | 10 ---------- .../amsterdam/eip8246_selfdestruct_no_burn/conftest.py | 10 ---------- 6 files changed, 57 deletions(-) delete mode 100644 tests/amsterdam/eip7708_eth_transfer_logs/conftest.py delete mode 100644 tests/amsterdam/eip7843_slotnum/conftest.py delete mode 100644 tests/amsterdam/eip7997_deterministic_factory_predeploy/conftest.py delete mode 100644 tests/amsterdam/eip8024_dupn_swapn_exchange/conftest.py delete mode 100644 tests/amsterdam/eip8246_selfdestruct_no_burn/conftest.py diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/conftest.py b/tests/amsterdam/eip7708_eth_transfer_logs/conftest.py deleted file mode 100644 index fb1d9465b3e..00000000000 --- a/tests/amsterdam/eip7708_eth_transfer_logs/conftest.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Pytest (plugin) definitions local to EIP-7708 tests.""" - -import pytest - - -def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: - """Mark all tests in this subdir as not valid for Monad forks.""" - metafunc.definition.add_marker( - pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) - ) diff --git a/tests/amsterdam/eip7843_slotnum/conftest.py b/tests/amsterdam/eip7843_slotnum/conftest.py deleted file mode 100644 index 6e43f18fb35..00000000000 --- a/tests/amsterdam/eip7843_slotnum/conftest.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Pytest (plugin) definitions local to EIP-7843 tests.""" - -import pytest - - -def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: - """Mark all tests in this subdir as not valid for Monad forks.""" - metafunc.definition.add_marker( - pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) - ) diff --git a/tests/amsterdam/eip7976_increase_calldata_floor_cost/conftest.py b/tests/amsterdam/eip7976_increase_calldata_floor_cost/conftest.py index 279a69479d5..9a252257297 100644 --- a/tests/amsterdam/eip7976_increase_calldata_floor_cost/conftest.py +++ b/tests/amsterdam/eip7976_increase_calldata_floor_cost/conftest.py @@ -23,13 +23,6 @@ from .helpers import DataTestType, find_floor_cost_threshold -def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: - """Mark all tests in this subdir as not valid for Monad forks.""" - metafunc.definition.add_marker( - pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) - ) - - @pytest.fixture def to( request: pytest.FixtureRequest, diff --git a/tests/amsterdam/eip7997_deterministic_factory_predeploy/conftest.py b/tests/amsterdam/eip7997_deterministic_factory_predeploy/conftest.py deleted file mode 100644 index de93abfcfef..00000000000 --- a/tests/amsterdam/eip7997_deterministic_factory_predeploy/conftest.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Pytest (plugin) definitions local to EIP-7997 tests.""" - -import pytest - - -def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: - """Mark all tests in this subdir as not valid for Monad forks.""" - metafunc.definition.add_marker( - pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) - ) diff --git a/tests/amsterdam/eip8024_dupn_swapn_exchange/conftest.py b/tests/amsterdam/eip8024_dupn_swapn_exchange/conftest.py deleted file mode 100644 index d8c781bb489..00000000000 --- a/tests/amsterdam/eip8024_dupn_swapn_exchange/conftest.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Pytest (plugin) definitions local to EIP-8024 tests.""" - -import pytest - - -def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: - """Mark all tests in this subdir as not valid for Monad forks.""" - metafunc.definition.add_marker( - pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) - ) diff --git a/tests/amsterdam/eip8246_selfdestruct_no_burn/conftest.py b/tests/amsterdam/eip8246_selfdestruct_no_burn/conftest.py deleted file mode 100644 index 4fb53dba402..00000000000 --- a/tests/amsterdam/eip8246_selfdestruct_no_burn/conftest.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Pytest (plugin) definitions local to EIP-8246 tests.""" - -import pytest - - -def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: - """Mark all tests in this subdir as not valid for Monad forks.""" - metafunc.definition.add_marker( - pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) - ) From d0a9612636276d1a605f6ad3c2e34edb08837850 Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:48:18 +0000 Subject: [PATCH 21/28] test(selfdestruct): expect no Burn log now that EIP-8246 joins EIP-7708 EIP-8246 keeps the balance a swept account holds, so the sweep emits a Transfer log or nothing at all. Co-Authored-By: Claude --- .../eip7708_eth_transfer_logs/spec.py | 13 -- .../eip6780_selfdestruct/test_selfdestruct.py | 144 +++++++----------- 2 files changed, 56 insertions(+), 101 deletions(-) diff --git a/tests/amsterdam/eip7708_eth_transfer_logs/spec.py b/tests/amsterdam/eip7708_eth_transfer_logs/spec.py index 11ba0ebf214..56a42e2850a 100644 --- a/tests/amsterdam/eip7708_eth_transfer_logs/spec.py +++ b/tests/amsterdam/eip7708_eth_transfer_logs/spec.py @@ -30,7 +30,6 @@ class Spec: TRANSFER_TOPIC: Hash = Hash( keccak256(b"Transfer(address,address,uint256)") ) - BURN_TOPIC: Hash = Hash(keccak256(b"Burn(address,uint256)")) def transfer_log( @@ -46,15 +45,3 @@ def transfer_log( ], data=Bytes(amount.to_bytes(32, "big")), ) - - -def burn_log(contract_address: Address, amount: int) -> TransactionLog: - """Create an expected Burn log for EIP-7708.""" - return TransactionLog( - address=Spec.SYSTEM_ADDRESS, - topics=[ - Spec.BURN_TOPIC, - Hash(bytes(contract_address).rjust(32, b"\x00")), - ], - data=Bytes(amount.to_bytes(32, "big")), - ) diff --git a/tests/cancun/eip6780_selfdestruct/test_selfdestruct.py b/tests/cancun/eip6780_selfdestruct/test_selfdestruct.py index 21b2734529f..24a80ceca9e 100644 --- a/tests/cancun/eip6780_selfdestruct/test_selfdestruct.py +++ b/tests/cancun/eip6780_selfdestruct/test_selfdestruct.py @@ -24,16 +24,12 @@ StateTestFiller, Storage, Transaction, - TransactionLog, TransactionReceipt, compute_create_address, ) from execution_testing.forks import MONAD_EIGHT, Cancun -from tests.amsterdam.eip7708_eth_transfer_logs.spec import ( - burn_log, - transfer_log, -) +from tests.amsterdam.eip7708_eth_transfer_logs.spec import transfer_log REFERENCE_SPEC_GIT_PATH = "EIPS/eip-6780.md" REFERENCE_SPEC_VERSION = "1b6a0e94cc47e859b9866e570391cf37dc55059a" @@ -56,25 +52,6 @@ PRE_DEPLOY_CONTRACT_3 = "pre_deploy_contract_3" -def sweep_log( - fork: Fork, - contract_address: Address, - recipient: Address, - amount: int, -) -> TransactionLog | None: - """ - Return the EIP-7708 log a SELFDESTRUCT sweep emits, if any. - - A sweep to another account transfers. A sweep to self burns the - balance, until EIP-8246 keeps it and emits nothing. - """ - if recipient != contract_address: - return transfer_log(contract_address, recipient, amount) - if fork.is_eip_enabled(8246): - return None - return burn_log(contract_address, amount) - - @pytest.fixture def eip_enabled(fork: Fork) -> bool: """Whether the EIP is enabled or not.""" @@ -347,14 +324,14 @@ def test_create_selfdestruct_same_tx( # SELFDESTRUCT emits a Transfer log to a different address, or a Burn # log when sending to self (contract was created in this tx). if selfdestruct_contract_current_balance > 0: - sweep = sweep_log( - fork, - selfdestruct_contract_address, - sendall_recipient, - selfdestruct_contract_current_balance, - ) - if sweep is not None: - expected_logs_after_tx_value.append(sweep) + if sendall_recipient != selfdestruct_contract_address: + expected_logs_after_tx_value.append( + transfer_log( + selfdestruct_contract_address, + sendall_recipient, + selfdestruct_contract_current_balance, + ) + ) # Balance is always sent to other contracts if sendall_recipient != selfdestruct_contract_address: @@ -592,14 +569,13 @@ def test_self_destructing_initcode( ) # Initcode SELFDESTRUCT sends pre-existing balance to the recipient. if selfdestruct_contract_initial_balance > 0: - sweep = sweep_log( - fork, - selfdestruct_contract_address, - sendall_recipient_addresses[0], - selfdestruct_contract_initial_balance, + expected_logs.append( + transfer_log( + selfdestruct_contract_address, + sendall_recipient_addresses[0], + selfdestruct_contract_initial_balance, + ) ) - if sweep is not None: - expected_logs.append(sweep) # CALLs to the destroyed contract transfer ETH to it. for i in range(call_times): if i > 0: @@ -608,12 +584,6 @@ def test_self_destructing_initcode( entry_code_address, selfdestruct_contract_address, i ) ) - # Whatever the calls left on the account is burned when the - # account is deleted, until EIP-8246 keeps it. - if entry_code_balance > 0 and not fork.is_eip_enabled(8246): - expected_logs.append( - burn_log(selfdestruct_contract_address, entry_code_balance) - ) tx.expected_receipt = TransactionReceipt(logs=expected_logs) state_test(pre=pre, post=post, tx=tx) @@ -687,14 +657,13 @@ def test_self_destructing_initcode_create_tx( transfer_log(sender, selfdestruct_contract_address, tx_value) ) if sendall_amount > 0: - sweep = sweep_log( - fork, - selfdestruct_contract_address, - sendall_recipient_addresses[0], - sendall_amount, + expected_logs.append( + transfer_log( + selfdestruct_contract_address, + sendall_recipient_addresses[0], + sendall_amount, + ) ) - if sweep is not None: - expected_logs.append(sweep) tx.expected_receipt = TransactionReceipt(logs=expected_logs) state_test(pre=pre, post=post, tx=tx) @@ -817,14 +786,17 @@ def test_recreate_self_destructed_contract_different_txs( # address with 0 balance (destroyed+cleared), so no log. tx_logs: list = [] if i == 0 and selfdestruct_contract_initial_balance > 0: - sweep = sweep_log( - fork, - selfdestruct_contract_address, - sendall_recipient_addresses[0], - selfdestruct_contract_initial_balance, - ) - if sweep is not None: - tx_logs.append(sweep) + if ( + sendall_recipient_addresses[0] + != selfdestruct_contract_address + ): + tx_logs.append( + transfer_log( + selfdestruct_contract_address, + sendall_recipient_addresses[0], + selfdestruct_contract_initial_balance, + ) + ) expected_receipt = TransactionReceipt(logs=tx_logs) txs.append( Transaction( @@ -1016,14 +988,13 @@ def test_selfdestruct_pre_existing( sendall_recipient != selfdestruct_contract_address and selfdestruct_contract_current_balance > 0 ): - sweep = sweep_log( - fork, - selfdestruct_contract_address, - sendall_recipient, - selfdestruct_contract_current_balance, + expected_logs_after_tx_value.append( + transfer_log( + selfdestruct_contract_address, + sendall_recipient, + selfdestruct_contract_current_balance, + ) ) - if sweep is not None: - expected_logs_after_tx_value.append(sweep) # Balance is always sent to other contracts if sendall_recipient != selfdestruct_contract_address: @@ -1221,14 +1192,13 @@ def test_selfdestruct_created_same_block_different_tx( ) running_balance += i if running_balance > 0: - sweep = sweep_log( - fork, - selfdestruct_contract_address, - sendall_recipient_addresses[0], - running_balance, + tx2_logs.append( + transfer_log( + selfdestruct_contract_address, + sendall_recipient_addresses[0], + running_balance, + ) ) - if sweep is not None: - tx2_logs.append(sweep) running_balance = 0 tx2_receipt = TransactionReceipt(logs=tx2_logs) @@ -1414,14 +1384,13 @@ def test_calling_from_new_contract_to_pre_existing_contract( ) running_balance += i if running_balance > 0: - sweep = sweep_log( - fork, - selfdestruct_contract_address, - sendall_recipient_addresses[0], - running_balance, + expected_logs.append( + transfer_log( + selfdestruct_contract_address, + sendall_recipient_addresses[0], + running_balance, + ) ) - if sweep is not None: - expected_logs.append(sweep) running_balance = 0 tx.expected_receipt = TransactionReceipt(logs=expected_logs) @@ -1759,14 +1728,13 @@ def test_create_selfdestruct_same_tx_increased_nonce( # (SELF_ADDRESS is not parametrized here), so a Transfer log is # emitted whenever the contract has a nonzero balance. if selfdestruct_contract_current_balance > 0: - sweep = sweep_log( - fork, - selfdestruct_contract_address, - sendall_recipient, - selfdestruct_contract_current_balance, + expected_logs_after_tx_value.append( + transfer_log( + selfdestruct_contract_address, + sendall_recipient, + selfdestruct_contract_current_balance, + ) ) - if sweep is not None: - expected_logs_after_tx_value.append(sweep) # Balance is always sent to other contracts if sendall_recipient != selfdestruct_contract_address: From b39d2900d14379e14d2a0020da0bcdad5b6ecd9a Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:48:27 +0000 Subject: [PATCH 22/28] feat(ci): release the merged MONAD_NEXT fixtures under the EIP bundle Name the six adopted EIPs in the fork docstring and the release feature. Co-Authored-By: Claude --- .github/configs/feature.yaml | 14 +------------ src/ethereum/forks/monad_next/__init__.py | 25 +++++++++++++++++++---- 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/.github/configs/feature.yaml b/.github/configs/feature.yaml index 78bb44a95bc..8ec546059a8 100644 --- a/.github/configs/feature.yaml +++ b/.github/configs/feature.yaml @@ -7,18 +7,6 @@ monad_runloop: # Like `monad`, but `--monad-runloop` and eestnet chain id `30143` fill-params: -m blockchain_test --from=MONAD_EIGHT --until=MONAD_TEN --chain-id=30143 --monad-runloop -k "not invalid_header" -monad_amsterdam: - evm-type: eels - fill-params: -m blockchain_test --fork=MONAD_NEXT --chain-id=143 -k "not invalid_header" - -monad_eip7981: - evm-type: eels - fill-params: -m blockchain_test --fork=MONAD_NEXT --chain-id=143 -k "not invalid_header" - -monad_eip7997: - evm-type: eels - fill-params: -m blockchain_test --fork=MONAD_NEXT --chain-id=143 -k "not invalid_header" - -monad_eip8246: +monad_eip7708+7843+7981+7997+8024+8246: evm-type: eels fill-params: -m blockchain_test --fork=MONAD_NEXT --chain-id=143 -k "not invalid_header" diff --git a/src/ethereum/forks/monad_next/__init__.py b/src/ethereum/forks/monad_next/__init__.py index 2e989370ce9..fe79f2c61c8 100644 --- a/src/ethereum/forks/monad_next/__init__.py +++ b/src/ethereum/forks/monad_next/__init__.py @@ -1,10 +1,27 @@ """ -MONAD_NEXT fork is a placeholder for upcoming Monad changes. It builds on -MONAD_TEN, adopting EIP-7708, EIP-7843 and EIP-8024 from Amsterdam -together with the Amsterdam block header layout; the [EIP-7928] block -access list hash header slot is carried but always zero. +MONAD_NEXT fork builds on MONAD_TEN, adopting part of Amsterdam. +The Amsterdam changes it does not adopt stay out of the fork entirely. +The [EIP-7928] block access list hash header slot is carried but always +zero, so the header layout matches Amsterdam while no block access list +is built. + +### Changes + +- [EIP-7708: ETH transfers emit a log][EIP-7708] +- [EIP-7843: SLOTNUM][EIP-7843] +- [EIP-7981: Increase Access List Cost][EIP-7981] +- [EIP-7997: Deterministic Factory Predeploy][EIP-7997] +- [EIP-8024: Stack Access Instructions][EIP-8024] +- [EIP-8246: Remove SELFDESTRUCT balance burn][EIP-8246] + +[EIP-7708]: https://eips.ethereum.org/EIPS/eip-7708 +[EIP-7843]: https://eips.ethereum.org/EIPS/eip-7843 [EIP-7928]: https://eips.ethereum.org/EIPS/eip-7928 +[EIP-7981]: https://eips.ethereum.org/EIPS/eip-7981 +[EIP-7997]: https://eips.ethereum.org/EIPS/eip-7997 +[EIP-8024]: https://eips.ethereum.org/EIPS/eip-8024 +[EIP-8246]: https://eips.ethereum.org/EIPS/eip-8246 """ from ethereum.fork_criteria import ByTimestamp, ForkCriteria From 2ba48ddfba97b3ddb5bf8d69e48eb5d839b75a41 Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:24:32 +0000 Subject: [PATCH 23/28] docs(skills): add the EIP branch merge skill Record how the single-EIP adoption branches consolidate onto one fork. Co-Authored-By: Claude --- .claude/commands/merge-eip-branches.md | 150 +++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 .claude/commands/merge-eip-branches.md diff --git a/.claude/commands/merge-eip-branches.md b/.claude/commands/merge-eip-branches.md new file mode 100644 index 00000000000..05c4f068975 --- /dev/null +++ b/.claude/commands/merge-eip-branches.md @@ -0,0 +1,150 @@ +# Merge EIP Adoption Branches + +Consolidate several single-EIP adoption branches into one fork that adopts +all of them. Run this skill before starting such a merge. Each source +branch was built by `/adopt-upstream-eip`, alone, against its own upstream +EIP branch, so no source branch knows what the combined fork does. + +## Inputs + +- The source branches, and `` they all target. +- ``, the branch they merge into. Example: `forks/monad_nine`. +- `` and its upstream integration branch, which already has every + EIP composed. Example: `upstream/forks/amsterdam`. + +## Read First + +`/adopt-upstream-eip` defines the fork shape, the shared-file cost rules, +the step-12 cleanup list and the definition of done. This skill only covers +what the merge adds. + +## 1. Prepare + +```bash +git worktree add -b origin/ +git branch --unset-upstream +``` + +Merge into a fresh worktree, and never check out or rewrite a source +branch. Note which commits already landed on `` after the +source branches were cut: a branch carrying its own copy of one of them +conflicts, and ``'s version wins. + +```bash +git log --oneline $(git merge-base origin/ )..origin/ +``` + +## 2. Merge + +Merge the largest branch first, then the rest, with real merge commits. +Squashing or cherry-picking hides which branch each conflict came from. + +```bash +git merge # once per source branch +``` + +## 3. Resolve the Conflicts Git Reports + +Expect these, in rising order of thought needed: + +- **`.github/configs/feature.yaml`** — every branch appends its own entry + at the same point. Keep all entries for now; step 5 collapses them. +- **`forks.py`, the `` declaration** — each branch declares the + class with one mixin. The merged class lists every mixin ahead of the + parent fork, in ascending EIP order. Drop any dead `pass` left after + `follows()`. +- **CI files already on ``** — keep the base branch's version. +- **`src/ethereum/forks//`** — two EIPs changing the same file. + Resolve against the upstream integration branch, not against either + branch alone. + +What must merge silently: the `follows()` trait, the +`BaseForkMeta._is_subclass_of` walk, the `ValidAtTransitionTo` guard and the +exclusion conftests. They are byte-identical across branches by design. +Check afterwards that one copy of each survived. + +## 4. Find the Silent Merges + +This is the main risk. Two branches editing the same file at different +lines merge cleanly and produce behavior neither branch had. Git reports +nothing. + +Find the candidates from the upstream spec changes: + +```bash +for n in ; do + base=$(git merge-base upstream/eips//eip-$n) + echo "### EIP-$n" + git diff --stat $base..upstream/eips//eip-$n -- src/ethereum/forks/ +done +``` + +Any file listed under two EIPs needs reading in full on the merge branch. +Where one EIP removes what another adds, the merged file must match the +upstream integration branch, which already has both. + +Two shapes seen in practice: + +- One EIP adds a log for a state change a later EIP removes. Git keeps + both: a log for something that no longer happens. +- Two branches add the same conftest hook at different offsets, one after + the imports and one appended. Git keeps both hook definitions in one + module. + +Also read every file the merge touched outside `src/ethereum/forks/monad*` +and `tests/monad*`, whether or not git flagged it. + +## 5. Clean Up the Scaffolding + +Follow `/adopt-upstream-eip` step 12, driven by what the merged fork now +adopts: + +- Delete the exclusion conftest for each adopted EIP's suite. Those suites + must now be selected and pass. +- Collapse the per-branch feature entries into one. When the branch name + follows `eips//eip-+`, the feature name must carry the same + numbers in the same order: `check_release_matrix.py` matches the sequence + as a literal string. Verify it: + + ```bash + python3 .github/scripts/check_release_matrix.py "" + ``` + +- Revert expectation scaffolding written for an intermediate fork shape. A + test edit that only made a fork with EIP-A but without EIP-B green is + scaffolding once B joins. + +To test whether an edit was scaffolding, revert it and diff against the +base branch: + +```bash +git revert --no-commit && git diff origin/ -- +``` + +An empty diff means the edit existed only for the intermediate shape. +Reverting cleanly needs the source branch to have kept scaffolding in its +own commit; a commit mixing scaffolding with permanent change has to be +undone by hand. + +Keep gates on a capability (`fork.is_eip_enabled()`, `fork >= +MONAD_EIGHT`) that stay true on the merged fork. They are permanent. + +## 6. Validate + +Run `/adopt-upstream-eip`'s step-3 shape check, its step-5 `--collect-only` +selection check, then the full-suite fill for the fork. The full suite +matters: the merge changes which suites select the fork. + +Measure any exclusion that survived the cleanup, one suite at a time, by +stripping its hook and filling that suite. State which ones you did not get +to measure. + +## Definition of Done + +- Every source branch merged, with the merge commits kept. +- Each file two EIPs touched read in full and matched against the upstream + integration branch. +- One copy of each shared addition; no duplicated hook or trait. +- Adopted suites selected and passing; their exclusions deleted. +- One feature entry, its name matching the branch's EIP sequence. +- Full-suite fill green, with any unmeasured exclusion named. From 02880af26a882d0d1e8a536ba07c671aaa16fc02 Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:31:17 +0000 Subject: [PATCH 24/28] test(eip7997): select the transition tests by EIP, not by Amsterdam The fork-named marker skipped every fork that adopts EIP-7997 on its own. Co-Authored-By: Claude --- .../test_fork_transition.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/amsterdam/eip7997_deterministic_factory_predeploy/test_fork_transition.py b/tests/amsterdam/eip7997_deterministic_factory_predeploy/test_fork_transition.py index 8f3d20b983b..2a92c2de067 100644 --- a/tests/amsterdam/eip7997_deterministic_factory_predeploy/test_fork_transition.py +++ b/tests/amsterdam/eip7997_deterministic_factory_predeploy/test_fork_transition.py @@ -32,7 +32,7 @@ FORK_TIMESTAMP = 15_000 -@pytest.mark.valid_at_transition_to("Amsterdam") +@pytest.mark.valid_at_transition_to("EIP7997") @pytest.mark.pre_alloc_mutable @pytest.mark.parametrize("pre_fork_nonce", [1, 2, 32]) def test_factory_deploys_across_transition( @@ -106,7 +106,7 @@ def test_factory_deploys_across_transition( ) -@pytest.mark.valid_at_transition_to("Amsterdam") +@pytest.mark.valid_at_transition_to("EIP7997") @pytest.mark.pre_alloc_mutable def test_factory_absent_across_transition( blockchain_test: BlockchainTestFiller, From a1f25523034dc67d5bf4615f086cd51b489c5e6c Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:27:02 +0000 Subject: [PATCH 25/28] test(benchmark): drop the inert EIP-7928 exclusions Filling without a path never reaches tests/benchmark, so neither hook was load-bearing. Co-Authored-By: Claude --- .../eip7928_block_level_access_lists/conftest.py | 10 ---------- .../eip7928_block_level_access_lists/conftest.py | 10 ---------- 2 files changed, 20 deletions(-) delete mode 100644 tests/benchmark/compute/eip7928_block_level_access_lists/conftest.py delete mode 100644 tests/benchmark/stateful/eip7928_block_level_access_lists/conftest.py diff --git a/tests/benchmark/compute/eip7928_block_level_access_lists/conftest.py b/tests/benchmark/compute/eip7928_block_level_access_lists/conftest.py deleted file mode 100644 index 9dc5216d7ea..00000000000 --- a/tests/benchmark/compute/eip7928_block_level_access_lists/conftest.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Pytest (plugin) definitions local to EIP-7928 benchmark tests.""" - -import pytest - - -def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: - """Mark all tests in this subdir as not valid for Monad forks.""" - metafunc.definition.add_marker( - pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) - ) diff --git a/tests/benchmark/stateful/eip7928_block_level_access_lists/conftest.py b/tests/benchmark/stateful/eip7928_block_level_access_lists/conftest.py deleted file mode 100644 index 9dc5216d7ea..00000000000 --- a/tests/benchmark/stateful/eip7928_block_level_access_lists/conftest.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Pytest (plugin) definitions local to EIP-7928 benchmark tests.""" - -import pytest - - -def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: - """Mark all tests in this subdir as not valid for Monad forks.""" - metafunc.definition.add_marker( - pytest.mark.not_valid_for("MONAD_EIGHT", subsequent_forks=True) - ) From 2eaeb1ddfd5bf54967768b1fb34da7524ff019b9 Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:27:02 +0000 Subject: [PATCH 26/28] refactor(specs): append the fixed block access list hash field Keep the header call as it stands and merge the field in as a mapping. Co-Authored-By: Claude --- .../src/execution_testing/specs/blockchain.py | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index 8b996720f20..cd2f85cf333 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -986,23 +986,28 @@ def generate_block_data( int(env.slot_number) if env.slot_number is not None else 0 ) - header_fields = transition_tool_output.result.model_dump( - exclude_none=True, - exclude={"blob_gas_used", "transactions_trie"}, - ) | env.model_dump( - exclude_none=True, - exclude={"blob_gas_used", "slot_number"}, - ) + # Prepare block_access_list_hash for header initialization + bal_hash_field: Dict[str, Any] = {} if fork.header_bal_hash_required() and ( not fork.supports_block_access_lists() ): # Fork requires the block access list hash header field but # doesn't build block access lists (e.g. Monad): fix value at # zero. - header_fields.setdefault("block_access_list_hash", Hash(0)) + bal_hash_field["block_access_list_hash"] = Hash(0) header = FixtureHeader( - **header_fields, + **( + transition_tool_output.result.model_dump( + exclude_none=True, + exclude={"blob_gas_used", "transactions_trie"}, + ) + | env.model_dump( + exclude_none=True, + exclude={"blob_gas_used", "slot_number"}, + ) + | bal_hash_field + ), blob_gas_used=blob_gas_used, transactions_trie=Transaction.list_root(txs), extra_data=( From b89a7f548570853ad4e0850872c81c097fa7f6b7 Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:51:03 +0000 Subject: [PATCH 27/28] test(monad_next): pin the Amsterdam EIP interactions with Monad features Cover the reserve balance sweep against a SELFDESTRUCT to the destructing account, keep MONAD_NEXT out of the deployed fork range, and stop fork succession from implying membership of the followed fork's EIPs. Co-Authored-By: Claude --- .../plugins/execute/rpc/hive.py | 6 +- .../src/execution_testing/forks/base_fork.py | 9 +- .../execution_testing/forks/forks/forks.py | 1 + .../forks/tests/test_forks.py | 55 ++++++ tests/monad_amsterdam/__init__.py | 1 + .../eip7997_delegated_create/__init__.py | 1 + .../test_factory_reach.py | 122 ++++++++++++ .../reserve_balance/test_transfers.py | 187 +++++++++++++++--- .../test_transfers.py | 148 +++++++++++--- 9 files changed, 472 insertions(+), 58 deletions(-) create mode 100644 tests/monad_amsterdam/__init__.py create mode 100644 tests/monad_amsterdam/eip7997_delegated_create/__init__.py create mode 100644 tests/monad_amsterdam/eip7997_delegated_create/test_factory_reach.py diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/rpc/hive.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/rpc/hive.py index 58ce18f480f..bbd287900a2 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/rpc/hive.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/rpc/hive.py @@ -183,7 +183,11 @@ def build_genesis_header( requests_hash=Requests() if genesis_fork.header_requests_required() else None, - block_access_list_hash=BlockAccessList().rlp_hash + block_access_list_hash=( + BlockAccessList().rlp_hash + if genesis_fork.supports_block_access_lists() + else Hash(0) + ) if genesis_fork.header_bal_hash_required() else None, slot_number=0 if genesis_fork.header_slot_number_required() else None, diff --git a/packages/testing/src/execution_testing/forks/base_fork.py b/packages/testing/src/execution_testing/forks/base_fork.py index 21801a25162..e480bce1e79 100644 --- a/packages/testing/src/execution_testing/forks/base_fork.py +++ b/packages/testing/src/execution_testing/forks/base_fork.py @@ -338,14 +338,19 @@ def _is_subclass_of(a: "BaseForkMeta", b: "BaseForkMeta") -> bool: A fork can follow another fork it does not inherit from, which places it after that fork (and after everything that fork comes - after) in the fork order without adopting its behavior. + after) in the fork order without adopting its behavior. The + EIPs of a followed fork stay out of that ordering. """ a = BaseForkMeta._maybe_transitioned(a) b = BaseForkMeta._maybe_transitioned(b) if issubclass(a, b): return True # The metaclass sees its instances as plain classes, so the - # trait is reached through a cast, as elsewhere in this class. + # traits are reached through a cast, as elsewhere in this class. + # Succession places a fork after another fork, not after that + # fork's EIPs; an adopted EIP is reached by inheritance above. + if cast(Type["BaseFork"], b).is_eip(): + return False followed = cast(Type["BaseFork"], a).follows() while followed is not None: if issubclass(followed, b): diff --git a/packages/testing/src/execution_testing/forks/forks/forks.py b/packages/testing/src/execution_testing/forks/forks/forks.py index dbbc09be2b2..05b17c4d4fd 100644 --- a/packages/testing/src/execution_testing/forks/forks/forks.py +++ b/packages/testing/src/execution_testing/forks/forks/forks.py @@ -1908,6 +1908,7 @@ class MONAD_NEXT( # noqa: N801 eips.EIP8024, eips.EIP8246, MONAD_TEN, + deployed=False, ): """ MONAD_NEXT fork. diff --git a/packages/testing/src/execution_testing/forks/tests/test_forks.py b/packages/testing/src/execution_testing/forks/tests/test_forks.py index ea747d480f2..592cca401a9 100644 --- a/packages/testing/src/execution_testing/forks/tests/test_forks.py +++ b/packages/testing/src/execution_testing/forks/tests/test_forks.py @@ -10,6 +10,7 @@ from execution_testing.vm import Opcodes from ..base_fork import BaseFork, BaseForkMeta +from ..forks.eips import EIP2780, EIP7708, EIP8024 from ..forks.eips.paris.eip_3675 import EIP3675 from ..forks.forks import ( BPO1, @@ -18,6 +19,8 @@ BPO4, BPO5, MONAD_EIGHT, + MONAD_NEXT, + MONAD_TEN, Amsterdam, Berlin, Cancun, @@ -296,6 +299,25 @@ def test_fork_comparison() -> None: assert fork == Berlin +def test_succession_does_not_imply_eip_membership() -> None: + """ + A fork placed after another by `follows` does not gain its EIPs. + + Succession orders MONAD_NEXT after Amsterdam so the Amsterdam + suites can select it, while the EIPs it declines must still compare + as absent. An adopted EIP is reached through inheritance instead, so + it keeps comparing as present. + """ + assert MONAD_NEXT > Amsterdam + assert MONAD_NEXT > MONAD_TEN + + assert MONAD_NEXT >= EIP7708 + assert MONAD_NEXT.is_eip_enabled(7708) + + assert not MONAD_NEXT >= EIP2780 + assert not MONAD_NEXT.is_eip_enabled(2780) + + def test_transition_fork_comparison() -> None: """ Test comparing to a transition fork. @@ -382,6 +404,39 @@ class PreAllocTransitionFork(TransitionBaseClass): pass +def test_monad_next_is_not_deployed() -> None: + """ + A fork under development must stay out of the deployed fork range. + """ + deployed_forks = get_deployed_forks() + assert MONAD_TEN in deployed_forks + assert MONAD_NEXT not in deployed_forks + + +def opcode_names(fork: Type[BaseFork]) -> set[str]: + """Return the names of the opcodes a fork declares valid.""" + return {str(opcode) for opcode in fork.valid_opcodes()} + + +def test_adopted_opcodes_depend_on_mixin_order() -> None: + """ + The adopted opcodes reach MONAD_NEXT through its mixin order. + + MONAD_NINE returning Osaka's list directly is what ends the chain + there, which is what lets a mixin placed ahead of the Monad forks + contribute on the way out. Making that call cooperative, or + reordering MONAD_NEXT's bases, drops these opcodes silently. + """ + adopted = {"DUPN", "SWAPN", "EXCHANGE"} + assert adopted <= opcode_names(MONAD_NEXT) + assert not adopted & opcode_names(MONAD_TEN) + + class MixinAfterFork(MONAD_TEN, EIP8024): # noqa: N801 + """Dummy fork ordering an EIP mixin after the Monad fork.""" + + assert not adopted & opcode_names(MixinAfterFork) + + def test_pre_alloc() -> None: # noqa: D103 assert PrePreAllocFork.pre_allocation() == {"test": "test"} assert PreAllocFork.pre_allocation() == {"test": "test", "test2": "test2"} diff --git a/tests/monad_amsterdam/__init__.py b/tests/monad_amsterdam/__init__.py new file mode 100644 index 00000000000..b37e6faa138 --- /dev/null +++ b/tests/monad_amsterdam/__init__.py @@ -0,0 +1 @@ +"""Tests for the Amsterdam EIPs MONAD_NEXT adopts.""" diff --git a/tests/monad_amsterdam/eip7997_delegated_create/__init__.py b/tests/monad_amsterdam/eip7997_delegated_create/__init__.py new file mode 100644 index 00000000000..4cd6b5b4cb1 --- /dev/null +++ b/tests/monad_amsterdam/eip7997_delegated_create/__init__.py @@ -0,0 +1 @@ +"""Tests for the EIP-7997 factory against Monad's CREATE ban in 7702 frames.""" diff --git a/tests/monad_amsterdam/eip7997_delegated_create/test_factory_reach.py b/tests/monad_amsterdam/eip7997_delegated_create/test_factory_reach.py new file mode 100644 index 00000000000..f0c3382e86c --- /dev/null +++ b/tests/monad_amsterdam/eip7997_delegated_create/test_factory_reach.py @@ -0,0 +1,122 @@ +""" +Tests for reaching the EIP-7997 factory from a delegated frame. + +Monad forbids the create opcodes inside a frame executing an EOA's +delegated code. The ban is callee-scoped: `access_delegation` sets it on +the frame it builds, and a child frame the delegated code calls does not +inherit it. EIP-7997 makes a CREATE2 factory reachable at a fixed +address on every chain, so these tests pin both halves of that +boundary. +""" + +import pytest +from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + Hash, + Op, + Transaction, +) +from execution_testing.test_types.helpers import compute_create2_address +from execution_testing.tools.tools_code.generators import Initcode + +from ...amsterdam.eip7997_deterministic_factory_predeploy.spec import ( + Spec, + ref_spec_7997, +) + +REFERENCE_SPEC_GIT_PATH = ref_spec_7997.git_path +REFERENCE_SPEC_VERSION = ref_spec_7997.version + +SALT = 0x42 + +slot_code_worked = 0x1 +value_code_worked = 0x1234 +slot_call_result = 0x2 + +pytestmark = [ + pytest.mark.valid_from("MONAD_NEXT"), + pytest.mark.pre_alloc_group( + "eip7997_delegated_create_tests", + reason="Tests the EIP-7997 factory reached from a delegated frame", + ), +] + + +@pytest.mark.parametrize("create_op", [Op.CREATE, Op.CREATE2]) +def test_create_halts_in_delegated_frame( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + create_op: Op, +) -> None: + """ + A create opcode executed as an EOA's delegated code halts the frame. + + The halt is the only coverage of `CreateIn7702Context`; nothing is + deployed and the storage write that follows never lands. + """ + # The create halts before its initcode is read, so the empty + # memory the size refers to is immaterial. + delegate = pre.deploy_contract( + create_op(value=0, size=1) + + Op.SSTORE(slot_code_worked, value_code_worked) + ) + sender = pre.fund_eoa() + delegated = pre.fund_eoa(delegation=delegate) + + tx = Transaction( + to=delegated, + sender=sender, + ) + + blockchain_test( + pre=pre, + post={delegated: Account(storage={})}, + blocks=[Block(txs=[tx])], + ) + + +def test_factory_deploys_from_delegated_frame( + blockchain_test: BlockchainTestFiller, + pre: Alloc, +) -> None: + """ + A delegated frame reaches CREATE2 through the EIP-7997 factory. + + The ban applies to the frame running the delegated code, not to the + factory frame it calls, so the deployment succeeds. + """ + initcode = Initcode(deploy_code=Op.STOP) + deployed = compute_create2_address(Spec.FACTORY_ADDRESS, SALT, initcode) + + delegate = pre.deploy_contract( + Op.CALLDATACOPY(0, 0, Op.CALLDATASIZE) + + Op.SSTORE( + slot_call_result, + Op.CALL( + gas=Op.GAS, + address=Spec.FACTORY_ADDRESS, + args_offset=0, + args_size=Op.CALLDATASIZE, + ), + ) + ) + sender = pre.fund_eoa() + delegated = pre.fund_eoa(delegation=delegate) + + tx = Transaction( + to=delegated, + data=Hash(SALT) + bytes(initcode), + sender=sender, + ) + + blockchain_test( + pre=pre, + post={ + delegated: Account(storage={slot_call_result: 1}), + deployed: Account(code=Op.STOP), + }, + blocks=[Block(txs=[tx])], + ) diff --git a/tests/monad_eight/reserve_balance/test_transfers.py b/tests/monad_eight/reserve_balance/test_transfers.py index 5a06d5b19ce..6fabc7b20e1 100644 --- a/tests/monad_eight/reserve_balance/test_transfers.py +++ b/tests/monad_eight/reserve_balance/test_transfers.py @@ -572,6 +572,7 @@ def test_sc_wallet_send_value_with_selfdestruct( @pytest.mark.parametrize("pre_delegated", [True, False]) @pytest.mark.parametrize("delegate", [True, False]) @pytest.mark.parametrize("undelegate", [True, False]) +@pytest.mark.parametrize("selfdestruct_to_self", [True, False]) def test_sc_wallet_selfdestruct( blockchain_test: BlockchainTestFiller, pre: Alloc, @@ -581,16 +582,22 @@ def test_sc_wallet_selfdestruct( pre_delegated: bool, delegate: bool, undelegate: bool, + selfdestruct_to_self: bool, fork: Fork, ) -> None: """ Test reserve balance violations for a delegated EOA whose wallet code SELFDESTRUCTs on behalf of the EOA. - NOTE: this is different from test_sc_wallet_send_value_with_selfdestruct - in that SELFDESTRUCT is not used to send value. + Naming the EOA itself moves no value, so the reserve balance is only + at stake when the beneficiary is another account. """ - wallet_address = pre.deploy_contract(code=Op.SELFDESTRUCT(Op.ADDRESS)) + selfdestruct_target = Address(0x5656) + wallet_address = pre.deploy_contract( + code=Op.SELFDESTRUCT( + Op.ADDRESS if selfdestruct_to_self else selfdestruct_target + ) + ) if pre_delegated: sender = pre.fund_eoa(balance, delegation=wallet_address) @@ -629,7 +636,14 @@ def test_sc_wallet_selfdestruct( ) any_delegation = pre_delegated or delegate or undelegate - reverted = violation and any_delegation + # The authorizations apply before execution, so an undelegation + # leaves nothing for the call to run. + wallet_runs = (pre_delegated or delegate) and not undelegate + # Sweeping the sender to another account empties a delegated EOA, + # which no emptying transaction exception covers. + reverted = (violation and any_delegation) or ( + wallet_runs and not selfdestruct_to_self + ) storage = {} if reverted else {slot_code_worked: value_code_worked} blockchain_test( @@ -637,7 +651,8 @@ def test_sc_wallet_selfdestruct( post={ contract_address: Account( storage=storage, balance=value if not reverted else 0 - ) + ), + selfdestruct_target: None, }, blocks=[Block(txs=[tx_1])], ) @@ -1258,8 +1273,13 @@ def test_access_lists( @pytest.mark.parametrize("pre_delegated", [True, False]) @pytest.mark.parametrize("new_address_pre_funded", [True, False]) @pytest.mark.parametrize( - "selfdestruct,deploy_code", - [(True, None), (False, Bytecode()), (False, Op.STOP)], + "selfdestruct,selfdestruct_to_self,deploy_code", + [ + (True, False, None), + (True, True, None), + (False, False, Bytecode()), + (False, False, Op.STOP), + ], ) @pytest.mark.with_all_contract_creating_tx_types def test_creation_tx( @@ -1271,6 +1291,7 @@ def test_creation_tx( pre_delegated: bool, new_address_pre_funded: bool, selfdestruct: bool, + selfdestruct_to_self: bool, deploy_code: Bytecode | None, tx_type: int, fork: Fork, @@ -1287,7 +1308,9 @@ def test_creation_tx( selfdestruct_target = Address(0x5656) initcode = ( - Op.SELFDESTRUCT(address=selfdestruct_target) + Op.SELFDESTRUCT( + address=Op.ADDRESS if selfdestruct_to_self else selfdestruct_target + ) if selfdestruct else Initcode(deploy_code=deploy_code) ) @@ -1316,6 +1339,25 @@ def test_creation_tx( fork == MONAD_EIGHT and selfdestruct and new_address_pre_funded ) + # A SELFDESTRUCT to the destructing account itself moves nothing, so + # what it leaves behind is what EIP-8246 changes: before it, the + # balance is burnt and the account destroyed; after it, the account + # is cleared and keeps the balance it retained. A zero balance still + # leaves the account empty, and so pruned. + selfdestructed_account = ( + Account( + nonce=0, + balance=value + pre_fund_value, + code=b"", + storage={}, + ) + if selfdestruct_to_self + and fork.is_eip_enabled(8246) + and not reverted + and value + pre_fund_value != 0 + else None + ) + blockchain_test( pre=pre, post={ @@ -1326,9 +1368,12 @@ def test_creation_tx( if not reverted and not selfdestruct else Account(balance=pre_fund_value) if new_address_pre_funded and reverted - else None, + else selfdestructed_account, selfdestruct_target: Account(balance=value + pre_fund_value) - if selfdestruct and not reverted and value + pre_fund_value != 0 + if selfdestruct + and not selfdestruct_to_self + and not reverted + and value + pre_fund_value != 0 else None, }, blocks=[Block(txs=txs)], @@ -1405,8 +1450,13 @@ def test_contract_unrestricted( @pytest.mark.parametrize("pre_delegated", [True, False]) @pytest.mark.parametrize("pre_funded", [True, False]) @pytest.mark.parametrize( - "selfdestruct,deploy_code", - [(True, None), (False, Bytecode()), (False, Op.STOP)], + "selfdestruct,selfdestruct_to_self,deploy_code", + [ + (True, False, None), + (True, True, None), + (False, False, Bytecode()), + (False, False, Op.STOP), + ], ) @pytest.mark.with_all_create_opcodes def test_contract_unrestricted_with_create( @@ -1417,6 +1467,7 @@ def test_contract_unrestricted_with_create( pre_delegated: bool, pre_funded: bool, selfdestruct: bool, + selfdestruct_to_self: bool, deploy_code: Bytecode | None, create_opcode: Op, fork: Fork, @@ -1436,7 +1487,9 @@ def test_contract_unrestricted_with_create( selfdestruct_target = Address(0x5656) initcode = ( - Op.SELFDESTRUCT(address=selfdestruct_target) + Op.SELFDESTRUCT( + address=Op.ADDRESS if selfdestruct_to_self else selfdestruct_target + ) if selfdestruct else Initcode(deploy_code=deploy_code) ) @@ -1467,15 +1520,26 @@ def test_contract_unrestricted_with_create( ) storage = {slot_code_worked: value_code_worked} + # A SELFDESTRUCT to the destructing account itself moves nothing, so + # what it leaves behind is what EIP-8246 changes: before it, the + # balance is burnt and the account destroyed; after it, the account + # is cleared and keeps the balance it retained. A zero balance still + # leaves the account empty, and so pruned. + selfdestructed_account = ( + Account(nonce=0, balance=value, code=b"", storage={}) + if selfdestruct_to_self and fork.is_eip_enabled(8246) and value != 0 + else None + ) + blockchain_test( pre=pre, post={ factory_address: Account(storage=storage, balance=balance - value), new_contract_address: Account(balance=value, code=deploy_code) if not selfdestruct - else None, + else selfdestructed_account, selfdestruct_target: Account(balance=value) - if selfdestruct and value != 0 + if selfdestruct and value != 0 and not selfdestruct_to_self else None, }, blocks=[Block(txs=[tx_1])], @@ -1486,8 +1550,19 @@ def test_contract_unrestricted_with_create( @pytest.mark.parametrize("create_balance", [0, Spec.RESERVE_BALANCE // 2]) @pytest.mark.parametrize("call_balance", [0, Spec.RESERVE_BALANCE // 2]) @pytest.mark.parametrize("pull_balance", [0, Spec.RESERVE_BALANCE // 2]) -@pytest.mark.parametrize("same_tx", [True, False]) -@pytest.mark.parametrize("through_delegation", [True, False]) +@pytest.mark.parametrize( + "same_tx,through_delegation,selfdestruct_to_self", + [ + # Only a same-transaction creation reaches EIP-8246's branch, and + # a delegated frame destructs the delegating account rather than + # the created one, so the self target varies in one case alone. + (True, False, False), + (True, False, True), + (True, True, False), + (False, False, False), + (False, True, False), + ], +) @pytest.mark.with_all_create_opcodes def test_contract_unrestricted_with_selfdestruct( blockchain_test: BlockchainTestFiller, @@ -1506,6 +1581,8 @@ def test_contract_unrestricted_with_selfdestruct( # Whether the SELFDESTRUCT should be called on behalf of # a delegating account through_delegation: bool, + # Whether SELFDESTRUCT names the destructing account itself + selfdestruct_to_self: bool, create_opcode: Op, fork: Fork, ) -> None: @@ -1532,7 +1609,7 @@ def test_contract_unrestricted_with_selfdestruct( Op.SELFDESTRUCT(address=Op.CALLER), balance=pull_balance ) deploy_code = Op.CALL(address=pull_funder_address) + Op.SELFDESTRUCT( - address=selfdestruct_target + address=Op.ADDRESS if selfdestruct_to_self else selfdestruct_target ) initcode = Initcode(deploy_code=deploy_code) @@ -1620,6 +1697,16 @@ def test_contract_unrestricted_with_selfdestruct( storage = {slot_code_worked: value_code_worked} reverted = through_delegation and value > 0 and prefund_balance > 0 + # A SELFDESTRUCT to the destructing account itself moves nothing, so + # what it leaves behind is what EIP-8246 changes: before it, the + # balance is burnt and the account destroyed; after it, the account + # is cleared and keeps the balance it retained. + selfdestructed_account = ( + Account(nonce=0, balance=value, code=b"", storage={}) + if selfdestruct_to_self and fork.is_eip_enabled(8246) and value != 0 + else None + ) + blockchain_test( pre=pre, post={ @@ -1633,7 +1720,7 @@ def test_contract_unrestricted_with_selfdestruct( code=deploy_code, ) if not same_tx or through_delegation - else None, + else selfdestructed_account, # Delegated account is deleted if there is no delegation delegated_address: Account( balance=0, @@ -1643,7 +1730,7 @@ def test_contract_unrestricted_with_selfdestruct( else None, # SELFDESTRUCT target is deleted if source was empty selfdestruct_target: Account(balance=value) - if value != 0 + if value != 0 and not selfdestruct_to_self else None, } if not reverted @@ -1677,8 +1764,13 @@ def test_contract_unrestricted_with_selfdestruct( @pytest.mark.with_all_create_opcodes @pytest.mark.parametrize("new_address_pre_funded", [True, False]) @pytest.mark.parametrize( - "selfdestruct,deploy_code", - [(True, None), (False, Bytecode()), (False, Op.STOP)], + "selfdestruct,selfdestruct_to_self,deploy_code", + [ + (True, False, None), + (True, True, None), + (False, False, Bytecode()), + (False, False, Op.STOP), + ], ) def test_contract_unrestricted_within_initcode( blockchain_test: BlockchainTestFiller, @@ -1689,6 +1781,7 @@ def test_contract_unrestricted_within_initcode( create_opcode: Op, new_address_pre_funded: bool, selfdestruct: bool, + selfdestruct_to_self: bool, deploy_code: Bytecode | None, fork: Fork, ) -> None: @@ -1710,7 +1803,11 @@ def test_contract_unrestricted_within_initcode( initcode = ( ( Op.CALL(value=value, address=target) - + Op.SELFDESTRUCT(address=selfdestruct_target) + + Op.SELFDESTRUCT( + address=Op.ADDRESS + if selfdestruct_to_self + else selfdestruct_target + ) ) if selfdestruct else Initcode( @@ -1756,6 +1853,16 @@ def test_contract_unrestricted_within_initcode( ) storage = {} if reverted else {slot_code_worked: value_code_worked} + # A SELFDESTRUCT to the destructing account itself moves nothing, so + # what it leaves behind is what EIP-8246 changes: before it, the + # balance is burnt and the account destroyed; after it, the account + # is cleared and keeps the balance it retained. + selfdestructed_account = ( + Account(nonce=0, balance=balance - value, code=b"", storage={}) + if selfdestruct_to_self and fork.is_eip_enabled(8246) and not reverted + else None + ) + txs = [tx_1] if new_address_pre_funded: txs.insert( @@ -1773,12 +1880,12 @@ def test_contract_unrestricted_within_initcode( if reverted and new_address_pre_funded else Account(balance=balance - value, code=deploy_code) if not selfdestruct - else None, + else selfdestructed_account, target: Account(balance=value) if value != 0 and not reverted else None, selfdestruct_target: Account(balance=balance - value) - if selfdestruct and not reverted + if selfdestruct and not reverted and not selfdestruct_to_self else None, }, blocks=[Block(txs=txs)], @@ -1796,8 +1903,13 @@ def test_contract_unrestricted_within_initcode( @pytest.mark.parametrize("pre_delegated", [True, False]) @pytest.mark.parametrize("new_address_pre_funded", [True, False]) @pytest.mark.parametrize( - "selfdestruct,deploy_code", - [(True, None), (False, Bytecode()), (False, Op.STOP)], + "selfdestruct,selfdestruct_to_self,deploy_code", + [ + (True, False, None), + (True, True, None), + (False, False, Bytecode()), + (False, False, Op.STOP), + ], ) @pytest.mark.with_all_contract_creating_tx_types def test_unrestricted_in_creation_tx_initcode( @@ -1808,6 +1920,7 @@ def test_unrestricted_in_creation_tx_initcode( pre_delegated: bool, new_address_pre_funded: bool, selfdestruct: bool, + selfdestruct_to_self: bool, deploy_code: Bytecode | None, tx_type: int, fork: Fork, @@ -1830,7 +1943,11 @@ def test_unrestricted_in_creation_tx_initcode( initcode = ( ( Op.CALL(value=value, address=target) - + Op.SELFDESTRUCT(address=selfdestruct_target) + + Op.SELFDESTRUCT( + address=Op.ADDRESS + if selfdestruct_to_self + else selfdestruct_target + ) ) if selfdestruct else Initcode( @@ -1865,6 +1982,16 @@ def test_unrestricted_in_creation_tx_initcode( and balance - value < Spec.RESERVE_BALANCE ) + # A SELFDESTRUCT to the destructing account itself moves nothing, so + # what it leaves behind is what EIP-8246 changes: before it, the + # balance is burnt and the account destroyed; after it, the account + # is cleared and keeps the balance it retained. + selfdestructed_account = ( + Account(nonce=0, balance=balance - value, code=b"", storage={}) + if selfdestruct_to_self and fork.is_eip_enabled(8246) and not reverted + else None + ) + blockchain_test( pre=pre, post={ @@ -1872,12 +1999,12 @@ def test_unrestricted_in_creation_tx_initcode( if reverted and new_address_pre_funded else Account(code=deploy_code, balance=balance - value) if not selfdestruct - else None, + else selfdestructed_account, target: Account(balance=value) if value != 0 and not reverted else None, selfdestruct_target: Account(balance=balance - value) - if selfdestruct and not reverted + if selfdestruct and not reverted and not selfdestruct_to_self else None, }, blocks=[Block(txs=txs)], diff --git a/tests/monad_nine/mip4_checkreservebalance/test_transfers.py b/tests/monad_nine/mip4_checkreservebalance/test_transfers.py index c217fd2cd6e..ec4d6cdf523 100644 --- a/tests/monad_nine/mip4_checkreservebalance/test_transfers.py +++ b/tests/monad_nine/mip4_checkreservebalance/test_transfers.py @@ -417,6 +417,7 @@ def test_sc_wallet_send_value( @pytest.mark.parametrize("pre_delegated", [True, False]) @pytest.mark.parametrize("delegate", [True, False]) @pytest.mark.parametrize("undelegate", [True, False]) +@pytest.mark.parametrize("selfdestruct_to_self", [True, False]) def test_sc_wallet_selfdestruct( blockchain_test: BlockchainTestFiller, pre: Alloc, @@ -427,14 +428,23 @@ def test_sc_wallet_selfdestruct( pre_delegated: bool, delegate: bool, undelegate: bool, + selfdestruct_to_self: bool, fork: Fork, ) -> None: """ Test dippedIntoReserve() for a delegated EOA whose wallet code SELFDESTRUCTs on behalf of the EOA. + + Naming the EOA itself moves no value, so the reserve balance is only + at stake when the beneficiary is another account. """ refill_call = refill_factory() - wallet_address = pre.deploy_contract(code=Op.SELFDESTRUCT(Op.ADDRESS)) + selfdestruct_target = Address(0x5656) + wallet_address = pre.deploy_contract( + code=Op.SELFDESTRUCT( + Op.ADDRESS if selfdestruct_to_self else selfdestruct_target + ) + ) if pre_delegated: sender = pre.fund_eoa(balance, delegation=wallet_address) @@ -475,7 +485,17 @@ def test_sc_wallet_selfdestruct( ) any_delegation = pre_delegated or delegate or undelegate - expected_violation = 1 if (violation and any_delegation) else 0 + # The authorizations apply before execution, so an undelegation + # leaves nothing for the call to run. + wallet_runs = (pre_delegated or delegate) and not undelegate + # Sweeping the sender to another account empties a delegated EOA, + # which the probe reports whatever the transaction's own value does. + expected_violation = ( + 1 + if (violation and any_delegation) + or (wallet_runs and not selfdestruct_to_self) + else 0 + ) storage = { slot_violation_result: expected_violation, @@ -484,7 +504,14 @@ def test_sc_wallet_selfdestruct( blockchain_test( pre=pre, - post={contract_address: Account(storage=storage, balance=value)}, + post={ + contract_address: Account(storage=storage, balance=value), + # What the sweep sends depends on the gas billed, so only + # its presence is pinned. + selfdestruct_target: Account() + if wallet_runs and not selfdestruct_to_self + else None, + }, blocks=[Block(txs=[tx_1])], ) @@ -1078,7 +1105,10 @@ def test_contract_unrestricted( ) @pytest.mark.parametrize("pre_delegated", [True, False]) @pytest.mark.parametrize("pre_funded", [True, False]) -@pytest.mark.parametrize("selfdestruct", [True, False]) +@pytest.mark.parametrize( + "selfdestruct,selfdestruct_to_self", + [(True, False), (True, True), (False, False)], +) @pytest.mark.with_all_create_opcodes def test_contract_unrestricted_with_create( blockchain_test: BlockchainTestFiller, @@ -1088,6 +1118,7 @@ def test_contract_unrestricted_with_create( pre_delegated: bool, pre_funded: bool, selfdestruct: bool, + selfdestruct_to_self: bool, create_opcode: Op, fork: Fork, ) -> None: @@ -1105,7 +1136,9 @@ def test_contract_unrestricted_with_create( selfdestruct_target = Address(0x5656) initcode = ( - Op.SELFDESTRUCT(address=selfdestruct_target) + Op.SELFDESTRUCT( + address=Op.ADDRESS if selfdestruct_to_self else selfdestruct_target + ) if selfdestruct else Initcode(deploy_code=Op.STOP) ) @@ -1136,15 +1169,26 @@ def test_contract_unrestricted_with_create( ) storage = {slot_code_worked: value_code_worked, slot_violation_result: 0} + # A SELFDESTRUCT to the destructing account itself moves nothing, so + # what it leaves behind is what EIP-8246 changes: before it, the + # balance is burnt and the account destroyed; after it, the account + # is cleared and keeps the balance it retained. A zero balance still + # leaves the account empty, and so pruned. + selfdestructed_account = ( + Account(nonce=0, balance=value, code=b"", storage={}) + if selfdestruct_to_self and fork.is_eip_enabled(8246) and value != 0 + else None + ) + blockchain_test( pre=pre, post={ factory_address: Account(storage=storage, balance=balance - value), new_contract_address: Account(balance=value, code=Op.STOP) if not selfdestruct - else None, + else selfdestructed_account, selfdestruct_target: Account(balance=value) - if selfdestruct and value != 0 + if selfdestruct and value != 0 and not selfdestruct_to_self else None, }, blocks=[Block(txs=[tx_1])], @@ -1155,8 +1199,19 @@ def test_contract_unrestricted_with_create( @pytest.mark.parametrize("create_balance", [0, Spec.RESERVE_BALANCE // 2]) @pytest.mark.parametrize("call_balance", [0, Spec.RESERVE_BALANCE // 2]) @pytest.mark.parametrize("pull_balance", [0, Spec.RESERVE_BALANCE // 2]) -@pytest.mark.parametrize("same_tx", [True, False]) -@pytest.mark.parametrize("through_delegation", [True, False]) +@pytest.mark.parametrize( + "same_tx,through_delegation,selfdestruct_to_self", + [ + # Only a same-transaction creation reaches EIP-8246's branch, and + # a delegated frame destructs the delegating account rather than + # the created one, so the self target varies in one case alone. + (True, False, False), + (True, False, True), + (True, True, False), + (False, False, False), + (False, True, False), + ], +) @pytest.mark.with_all_create_opcodes def test_contract_unrestricted_with_selfdestruct( blockchain_test: BlockchainTestFiller, @@ -1176,6 +1231,8 @@ def test_contract_unrestricted_with_selfdestruct( # Whether the SELFDESTRUCT should be called on behalf of # a delegating account through_delegation: bool, + # Whether SELFDESTRUCT names the destructing account itself + selfdestruct_to_self: bool, create_opcode: Op, fork: Fork, ) -> None: @@ -1205,7 +1262,7 @@ def test_contract_unrestricted_with_selfdestruct( ) deploy_code = Op.CALL(address=pull_funder_address) + Op.SELFDESTRUCT( - address=selfdestruct_target + address=Op.ADDRESS if selfdestruct_to_self else selfdestruct_target ) initcode = Initcode(deploy_code=deploy_code) @@ -1309,6 +1366,16 @@ def test_contract_unrestricted_with_selfdestruct( if same_tx: factory_storage[slot_violation_result] = expected_violation + # A SELFDESTRUCT to the destructing account itself moves nothing, so + # what it leaves behind is what EIP-8246 changes: before it, the + # balance is burnt and the account destroyed; after it, the account + # is cleared and keeps the balance it retained. + selfdestructed_account = ( + Account(nonce=0, balance=value, code=b"", storage={}) + if selfdestruct_to_self and fork.is_eip_enabled(8246) and value != 0 + else None + ) + post = { # Factory is the caller to store result if same_tx # Factory is always left with no balance. @@ -1327,9 +1394,11 @@ def test_contract_unrestricted_with_selfdestruct( code=deploy_code, ) if not same_tx or through_delegation - else None, + else selfdestructed_account, # SELFDESTRUCT target is deleted if source was empty - selfdestruct_target: Account(balance=value) if value != 0 else None, + selfdestruct_target: Account(balance=value) + if value != 0 and not selfdestruct_to_self + else None, } blockchain_test( @@ -1350,8 +1419,13 @@ def test_contract_unrestricted_with_selfdestruct( @pytest.mark.with_all_create_opcodes @pytest.mark.parametrize("new_address_pre_funded", [True, False]) @pytest.mark.parametrize( - "selfdestruct,deploy_code", - [(True, None), (False, Bytecode()), (False, Op.STOP)], + "selfdestruct,selfdestruct_to_self,deploy_code", + [ + (True, False, None), + (True, True, None), + (False, False, Bytecode()), + (False, False, Op.STOP), + ], ) def test_contract_unrestricted_within_initcode( blockchain_test: BlockchainTestFiller, @@ -1362,6 +1436,7 @@ def test_contract_unrestricted_within_initcode( create_opcode: Op, new_address_pre_funded: bool, selfdestruct: bool, + selfdestruct_to_self: bool, deploy_code: Bytecode | None, fork: Fork, ) -> None: @@ -1392,7 +1467,7 @@ def test_contract_unrestricted_within_initcode( ) if selfdestruct: initcode = common_initcode + Op.SELFDESTRUCT( - address=selfdestruct_target + address=Op.ADDRESS if selfdestruct_to_self else selfdestruct_target ) else: initcode = Initcode( @@ -1462,11 +1537,18 @@ def test_contract_unrestricted_within_initcode( new_balance = balance - value + Spec.RESERVE_BALANCE - # EIP-8246 stops the end-of-transaction cleanup from burning the - # balance, so the refill that follows SELFDESTRUCT is left behind on - # a cleared account. + # A SELFDESTRUCT to the destructing account itself moves nothing, so + # what it leaves behind is what EIP-8246 changes: before it, the + # balance is burnt and the account destroyed; after it, the account + # is cleared and keeps both the retained balance and the refill. + retained = balance - value if selfdestruct_to_self else 0 selfdestructed_account = ( - Account(nonce=0, balance=Spec.RESERVE_BALANCE, code=b"", storage={}) + Account( + nonce=0, + balance=retained + Spec.RESERVE_BALANCE, + code=b"", + storage={}, + ) if fork.is_eip_enabled(8246) else None ) @@ -1505,7 +1587,7 @@ def test_contract_unrestricted_within_initcode( # SELFDESTRUCT runs during initcode (before factory # refill), so it sends balance - value only. selfdestruct_target: Account(balance=balance - value) - if selfdestruct + if selfdestruct and not selfdestruct_to_self else None, }, blocks=[Block(txs=txs)], @@ -1522,8 +1604,13 @@ def test_contract_unrestricted_within_initcode( ) @pytest.mark.parametrize("new_address_pre_funded", [True, False]) @pytest.mark.parametrize( - "selfdestruct,deploy_code", - [(True, None), (False, Bytecode()), (False, Op.STOP)], + "selfdestruct,selfdestruct_to_self,deploy_code", + [ + (True, False, None), + (True, True, None), + (False, False, Bytecode()), + (False, False, Op.STOP), + ], ) @pytest.mark.with_all_contract_creating_tx_types def test_unrestricted_in_creation_tx_initcode( @@ -1534,6 +1621,7 @@ def test_unrestricted_in_creation_tx_initcode( balance: int, new_address_pre_funded: bool, selfdestruct: bool, + selfdestruct_to_self: bool, deploy_code: Bytecode | None, tx_type: int, fork: Fork, @@ -1574,7 +1662,7 @@ def test_unrestricted_in_creation_tx_initcode( ) if selfdestruct: initcode = common_initcode + Op.SELFDESTRUCT( - address=selfdestruct_target + address=Op.ADDRESS if selfdestruct_to_self else selfdestruct_target ) else: initcode = Initcode( @@ -1605,6 +1693,16 @@ def test_unrestricted_in_creation_tx_initcode( new_balance = balance - value + Spec.RESERVE_BALANCE + # A SELFDESTRUCT to the destructing account itself moves nothing, so + # what it leaves behind is what EIP-8246 changes: before it, the + # balance is burnt and the account destroyed; after it, the account + # is cleared and keeps the balance it retained. + selfdestructed_account = ( + Account(nonce=0, balance=new_balance, code=b"", storage={}) + if selfdestruct_to_self and fork.is_eip_enabled(8246) + else None + ) + blockchain_test( pre=pre, post={ @@ -1615,10 +1713,10 @@ def test_unrestricted_in_creation_tx_initcode( ), new_address: Account(code=deploy_code, balance=new_balance) if not selfdestruct - else None, + else selfdestructed_account, target: Account(balance=value) if value != 0 else None, selfdestruct_target: Account(balance=new_balance) - if selfdestruct + if selfdestruct and not selfdestruct_to_self else None, }, blocks=[Block(txs=txs)], From 6d0cadc2c3824de9861e3bf40d3f30560a102c12 Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:51:03 +0000 Subject: [PATCH 28/28] fix(ci): rehearse a fixture release once per push A push to a branch with an open pull request fired both triggers, and the two events land in different concurrency groups, so neither run cancelled the other. Co-Authored-By: Claude --- .github/workflows/check_release.yaml | 21 +++++---------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/.github/workflows/check_release.yaml b/.github/workflows/check_release.yaml index 8b1f7447b6f..d26999766bf 100644 --- a/.github/workflows/check_release.yaml +++ b/.github/workflows/check_release.yaml @@ -6,9 +6,10 @@ run-name: ${{ github.event_name == 'workflow_dispatch' && format('Check Fixture # breaks surfaces on the branch that broke it rather than on the next # release attempt. # -# Two tiers, both running on every pull request and on pushes to the -# branches that release in parallel with the fork branch, -# `from-upstream` and `eips/**`. +# Two tiers, both running on pushes to the branches that release in +# parallel with the fork branch, `from-upstream` and `eips/**`. A pull +# request adds no trigger of its own: its pushes already run here, and +# an event per push and per synchronize rehearsed each push twice. # # `collect` is cheap: it reads the features the branch releases out of # `.github/configs/feature.yaml`, builds the release's job matrix @@ -32,15 +33,6 @@ on: - "whitelist.txt" - "docs/**" - "mkdocs.yml" - pull_request: - paths-ignore: - - "**.md" - - "LICENSE*" - - ".gitignore" - - ".vscode/**" - - "whitelist.txt" - - "docs/**" - - "mkdocs.yml" workflow_dispatch: inputs: depth: @@ -79,10 +71,7 @@ jobs: shell: bash env: INPUT_FEATURES: ${{ inputs.features }} - # The head branch, so a pull request from an EIP branch - # rehearses that branch's feature; `ref_name` is the merge ref - # on a pull request and the branch everywhere else. - BRANCH: ${{ github.head_ref || github.ref_name }} + BRANCH: ${{ github.ref_name }} run: | # The feature selection and the per-feature release matrix live # in (and are shared with the release workflow via)