From 5dfd7e220261840cddb925ee92511fcf3426ee0e Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Wed, 28 Jan 2026 10:35:02 +0100 Subject: [PATCH 1/7] Add mip3 feature release --- .github/configs/feature.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/configs/feature.yaml b/.github/configs/feature.yaml index 7dc42d5b99..95af954290 100644 --- a/.github/configs/feature.yaml +++ b/.github/configs/feature.yaml @@ -13,3 +13,10 @@ monad: # (triggered by tarball output) fails to proceed on no tests processed # in 1st phase (exit code 5) fill-params: --suppress-no-test-exit-code -m blockchain_test --from=MONAD_EIGHT --until=MONAD_NEXT --chain-id=143 -k "not eip4844 and not eip7002 and not eip7251 and not eip7685 and not eip6110 and not eip7594 and not eip7918 and not eip7610 and not eip7934 and not invalid_header" + +mip3: + evm-type: develop + # --suppress-no-test-exit-code works around a problem where multi-phase fill + # (triggered by tarball output) fails to proceed on no tests processed + # in 1st phase (exit code 5) + fill-params: --suppress-no-test-exit-code -m blockchain_test --from=MONAD_EIGHT --until=MONAD_NEXT --chain-id=143 -k "not eip4844 and not eip7002 and not eip7251 and not eip7685 and not eip6110 and not eip7594 and not eip7918 and not eip7610 and not eip7934 and not invalid_header" From 17f31b74216835632a57fe78e854676de6eb328a Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Fri, 30 Jan 2026 14:23:41 +0000 Subject: [PATCH 2/7] MIP-3 implementation & tests Co-Authored-By: Claude Opus 4.5 --- .../execution_testing/forks/forks/forks.py | 25 + src/ethereum/forks/monad_next/vm/__init__.py | 12 +- .../forks/monad_next/vm/exceptions.py | 11 + src/ethereum/forks/monad_next/vm/gas.py | 44 +- .../monad_next/vm/instructions/environment.py | 13 +- .../monad_next/vm/instructions/keccak.py | 4 +- .../forks/monad_next/vm/instructions/log.py | 4 +- .../monad_next/vm/instructions/memory.py | 15 +- .../monad_next/vm/instructions/system.py | 43 +- .../forks/monad_next/vm/interpreter.py | 16 +- src/ethereum/forks/monad_next/vm/memory.py | 12 +- .../evm_tools/t8n/evm_trace/eip3155.py | 8 +- .../monad_nine/mip3_linear_memory/__init__.py | 1 + .../monad_nine/mip3_linear_memory/helpers.py | 142 ++ tests/monad_nine/mip3_linear_memory/spec.py | 26 + .../mip3_linear_memory/test_gas_cost.py | 325 +++++ .../monad_nine/mip3_linear_memory/test_oom.py | 1179 +++++++++++++++++ .../mip3_linear_memory/test_oom_deep.py | 100 ++ 18 files changed, 1940 insertions(+), 40 deletions(-) create mode 100644 tests/monad_nine/mip3_linear_memory/__init__.py create mode 100644 tests/monad_nine/mip3_linear_memory/helpers.py create mode 100644 tests/monad_nine/mip3_linear_memory/spec.py create mode 100644 tests/monad_nine/mip3_linear_memory/test_gas_cost.py create mode 100644 tests/monad_nine/mip3_linear_memory/test_oom.py create mode 100644 tests/monad_nine/mip3_linear_memory/test_oom_deep.py diff --git a/packages/testing/src/execution_testing/forks/forks/forks.py b/packages/testing/src/execution_testing/forks/forks/forks.py index 15c70f80df..bf369ce41e 100644 --- a/packages/testing/src/execution_testing/forks/forks/forks.py +++ b/packages/testing/src/execution_testing/forks/forks/forks.py @@ -3400,6 +3400,31 @@ def transaction_gas_limit_cap( block_number=block_number, timestamp=timestamp ) + @classmethod + def memory_expansion_gas_calculator( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> MemoryExpansionGasCalculator: + """ + Return callable that calculates the gas cost of memory expansion for + the fork. + """ + gas_costs = cls.gas_costs( + block_number=block_number, timestamp=timestamp + ) + + def fn(*, new_bytes: int, previous_bytes: int = 0) -> int: + if new_bytes <= previous_bytes: + return 0 + new_words = ceiling_division(new_bytes, 32) + previous_words = ceiling_division(previous_bytes, 32) + + def c(w: int) -> int: + return gas_costs.G_MEMORY * w + + return c(new_words) - c(previous_words) + + return fn + class BPO1(Osaka, bpo_fork=True): """Mainnet BPO1 fork - Blob Parameter Only fork 1.""" diff --git a/src/ethereum/forks/monad_next/vm/__init__.py b/src/ethereum/forks/monad_next/vm/__init__.py index 22808bdbf3..19e6ebfba6 100644 --- a/src/ethereum/forks/monad_next/vm/__init__.py +++ b/src/ethereum/forks/monad_next/vm/__init__.py @@ -136,13 +136,23 @@ class Message: disable_create_opcodes: bool +@dataclass +class EvmMemory: + """ + Memory of the EVM. + """ + + data: bytearray + high_watermark_bytes: int + + @dataclass class Evm: """The internal state of the virtual machine.""" pc: Uint stack: List[U256] - memory: bytearray + memory: EvmMemory code: Bytes gas_left: Uint valid_jump_destinations: Set[Uint] diff --git a/src/ethereum/forks/monad_next/vm/exceptions.py b/src/ethereum/forks/monad_next/vm/exceptions.py index 1a75522a0b..82452af8e3 100644 --- a/src/ethereum/forks/monad_next/vm/exceptions.py +++ b/src/ethereum/forks/monad_next/vm/exceptions.py @@ -32,6 +32,17 @@ class Revert(EthereumException): pass +class RevertOnOOM(EthereumException): + """ + Raised when transaction memory limit is exceeded. + + Unlike other EVM exceptions this does not result in the consumption of all + gas. + """ + + pass + + class RevertOnReserveBalance(EthereumException): """ Raised when reserve balance is violated by a transaction. diff --git a/src/ethereum/forks/monad_next/vm/gas.py b/src/ethereum/forks/monad_next/vm/gas.py index 0e06aba9b3..00d6cabf71 100644 --- a/src/ethereum/forks/monad_next/vm/gas.py +++ b/src/ethereum/forks/monad_next/vm/gas.py @@ -21,8 +21,8 @@ from ..blocks import Header from ..transactions import BlobTransaction, Transaction -from . import Evm -from .exceptions import OutOfGasError +from . import Evm, EvmMemory +from .exceptions import OutOfGasError, RevertOnOOM GAS_JUMPDEST = Uint(1) GAS_BASE = Uint(2) @@ -78,6 +78,8 @@ TARGET_BLOB_GAS_PER_BLOCK = GAS_PER_BLOB * BLOB_SCHEDULE_TARGET BLOB_BASE_COST = Uint(2**13) BLOB_SCHEDULE_MAX = U64(9) + +MAX_TX_MEMORY_USAGE = 8 * 1024 * 1024 MIN_BLOB_GASPRICE = Uint(1) BLOB_BASE_FEE_UPDATE_FRACTION = Uint(5007716) @@ -161,8 +163,7 @@ def calculate_memory_gas_cost(size_in_bytes: Uint) -> Uint: """ size_in_words = ceil32(size_in_bytes) // Uint(32) linear_cost = size_in_words * GAS_MEMORY - quadratic_cost = size_in_words ** Uint(2) // Uint(512) - total_gas_cost = linear_cost + quadratic_cost + total_gas_cost = linear_cost try: return total_gas_cost except ValueError as e: @@ -170,7 +171,7 @@ def calculate_memory_gas_cost(size_in_bytes: Uint) -> Uint: def calculate_gas_extend_memory( - memory: bytearray, extensions: List[Tuple[U256, U256]] + memory: EvmMemory, extensions: List[Tuple[U256, U256]] ) -> ExtendMemory: """ Calculates the gas amount to extend memory. @@ -178,7 +179,7 @@ def calculate_gas_extend_memory( Parameters ---------- memory : - Memory contents of the EVM. + Memory object of the EVM. extensions: List of extensions to be made to the memory. Consists of a tuple of start position and size. @@ -190,7 +191,7 @@ def calculate_gas_extend_memory( """ size_to_extend = Uint(0) to_be_paid = Uint(0) - current_size = Uint(len(memory)) + current_size = Uint(len(memory.data)) for start_position, size in extensions: if size == 0: continue @@ -209,6 +210,31 @@ def calculate_gas_extend_memory( return ExtendMemory(to_be_paid, size_to_extend) +def update_memory_high_watermark( + evm: Evm, extend_memory: ExtendMemory +) -> None: + """ + Update the memory high watermark and check it doesn't exceed + MAX_TX_MEMORY_USAGE. + + Parameters + ---------- + evm : + The EVM object. + extend_memory : + The memory extension info from calculate_gas_extend_memory. + + Raises + ------ + RevertOnOOM + If the new memory size would exceed MAX_TX_MEMORY_USAGE. + + """ + evm.memory.high_watermark_bytes += int(extend_memory.expand_by) + if evm.memory.high_watermark_bytes > MAX_TX_MEMORY_USAGE: + raise RevertOnOOM + + def calculate_message_call_gas( value: U256, gas: Uint, @@ -245,11 +271,11 @@ def calculate_message_call_gas( """ call_stipend = Uint(0) if value == 0 else call_stipend if gas_left < extra_gas + memory_cost: - return MessageCallGas(gas + extra_gas, gas + call_stipend) + return MessageCallGas(gas, gas + call_stipend) gas = min(gas, max_message_call_gas(gas_left - memory_cost - extra_gas)) - return MessageCallGas(gas + extra_gas, gas + call_stipend) + return MessageCallGas(gas, gas + call_stipend) def max_message_call_gas(gas: Uint) -> Uint: diff --git a/src/ethereum/forks/monad_next/vm/instructions/environment.py b/src/ethereum/forks/monad_next/vm/instructions/environment.py index 28c595ee51..04423b8bac 100644 --- a/src/ethereum/forks/monad_next/vm/instructions/environment.py +++ b/src/ethereum/forks/monad_next/vm/instructions/environment.py @@ -35,6 +35,7 @@ calculate_blob_gas_price, calculate_gas_extend_memory, charge_gas, + update_memory_high_watermark, ) from ..stack import pop, push @@ -236,9 +237,10 @@ def calldatacopy(evm: Evm) -> None: evm.memory, [(memory_start_index, size)] ) charge_gas(evm, GAS_VERY_LOW + copy_gas_cost + extend_memory.cost) + update_memory_high_watermark(evm, extend_memory) # OPERATION - evm.memory += b"\x00" * extend_memory.expand_by + evm.memory.data += b"\x00" * extend_memory.expand_by value = buffer_read(evm.message.data, data_start_index, size) memory_write(evm.memory, memory_start_index, value) @@ -294,9 +296,10 @@ def codecopy(evm: Evm) -> None: evm.memory, [(memory_start_index, size)] ) charge_gas(evm, GAS_VERY_LOW + copy_gas_cost + extend_memory.cost) + update_memory_high_watermark(evm, extend_memory) # OPERATION - evm.memory += b"\x00" * extend_memory.expand_by + evm.memory.data += b"\x00" * extend_memory.expand_by value = buffer_read(evm.code, code_start_index, size) memory_write(evm.memory, memory_start_index, value) @@ -389,9 +392,10 @@ def extcodecopy(evm: Evm) -> None: access_gas_cost = GAS_COLD_ACCOUNT_ACCESS charge_gas(evm, access_gas_cost + copy_gas_cost + extend_memory.cost) + update_memory_high_watermark(evm, extend_memory) # OPERATION - evm.memory += b"\x00" * extend_memory.expand_by + evm.memory.data += b"\x00" * extend_memory.expand_by code = get_account(evm.message.block_env.state, address).code value = buffer_read(code, code_start_index, size) @@ -446,10 +450,11 @@ def returndatacopy(evm: Evm) -> None: evm.memory, [(memory_start_index, size)] ) charge_gas(evm, GAS_VERY_LOW + copy_gas_cost + extend_memory.cost) + update_memory_high_watermark(evm, extend_memory) if Uint(return_data_start_position) + Uint(size) > ulen(evm.return_data): raise OutOfBoundsRead - evm.memory += b"\x00" * extend_memory.expand_by + evm.memory.data += b"\x00" * extend_memory.expand_by value = evm.return_data[ return_data_start_position : return_data_start_position + size ] diff --git a/src/ethereum/forks/monad_next/vm/instructions/keccak.py b/src/ethereum/forks/monad_next/vm/instructions/keccak.py index 44ba2eb40b..7fe2f5798d 100644 --- a/src/ethereum/forks/monad_next/vm/instructions/keccak.py +++ b/src/ethereum/forks/monad_next/vm/instructions/keccak.py @@ -22,6 +22,7 @@ GAS_KECCAK256_WORD, calculate_gas_extend_memory, charge_gas, + update_memory_high_watermark, ) from ..memory import memory_read_bytes from ..stack import pop, push @@ -51,9 +52,10 @@ def keccak(evm: Evm) -> None: evm.memory, [(memory_start_index, size)] ) charge_gas(evm, GAS_KECCAK256 + word_gas_cost + extend_memory.cost) + update_memory_high_watermark(evm, extend_memory) # OPERATION - evm.memory += b"\x00" * extend_memory.expand_by + evm.memory.data += b"\x00" * extend_memory.expand_by data = memory_read_bytes(evm.memory, memory_start_index, size) hashed = keccak256(data) diff --git a/src/ethereum/forks/monad_next/vm/instructions/log.py b/src/ethereum/forks/monad_next/vm/instructions/log.py index a6e95b3170..d230d83e04 100644 --- a/src/ethereum/forks/monad_next/vm/instructions/log.py +++ b/src/ethereum/forks/monad_next/vm/instructions/log.py @@ -24,6 +24,7 @@ GAS_LOG_TOPIC, calculate_gas_extend_memory, charge_gas, + update_memory_high_watermark, ) from ..memory import memory_read_bytes from ..stack import pop @@ -64,9 +65,10 @@ def log_n(evm: Evm, num_topics: int) -> None: + GAS_LOG_TOPIC * Uint(num_topics) + extend_memory.cost, ) + update_memory_high_watermark(evm, extend_memory) # OPERATION - evm.memory += b"\x00" * extend_memory.expand_by + evm.memory.data += b"\x00" * extend_memory.expand_by if evm.message.is_static: raise WriteInStaticContext log_entry = Log( diff --git a/src/ethereum/forks/monad_next/vm/instructions/memory.py b/src/ethereum/forks/monad_next/vm/instructions/memory.py index 6e111051ee..2d3eafe0bd 100644 --- a/src/ethereum/forks/monad_next/vm/instructions/memory.py +++ b/src/ethereum/forks/monad_next/vm/instructions/memory.py @@ -23,6 +23,7 @@ GAS_VERY_LOW, calculate_gas_extend_memory, charge_gas, + update_memory_high_watermark, ) from ..memory import memory_read_bytes, memory_write from ..stack import pop, push @@ -50,9 +51,10 @@ def mstore(evm: Evm) -> None: ) charge_gas(evm, GAS_VERY_LOW + extend_memory.cost) + update_memory_high_watermark(evm, extend_memory) # OPERATION - evm.memory += b"\x00" * extend_memory.expand_by + evm.memory.data += b"\x00" * extend_memory.expand_by memory_write(evm.memory, start_position, value) # PROGRAM COUNTER @@ -81,9 +83,10 @@ def mstore8(evm: Evm) -> None: ) charge_gas(evm, GAS_VERY_LOW + extend_memory.cost) + update_memory_high_watermark(evm, extend_memory) # OPERATION - evm.memory += b"\x00" * extend_memory.expand_by + evm.memory.data += b"\x00" * extend_memory.expand_by normalized_bytes_value = Bytes([value & U256(0xFF)]) memory_write(evm.memory, start_position, normalized_bytes_value) @@ -109,9 +112,10 @@ def mload(evm: Evm) -> None: evm.memory, [(start_position, U256(32))] ) charge_gas(evm, GAS_VERY_LOW + extend_memory.cost) + update_memory_high_watermark(evm, extend_memory) # OPERATION - evm.memory += b"\x00" * extend_memory.expand_by + evm.memory.data += b"\x00" * extend_memory.expand_by value = U256.from_be_bytes( memory_read_bytes(evm.memory, start_position, U256(32)) ) @@ -138,7 +142,7 @@ def msize(evm: Evm) -> None: charge_gas(evm, GAS_BASE) # OPERATION - push(evm.stack, U256(len(evm.memory))) + push(evm.stack, U256(len(evm.memory.data))) # PROGRAM COUNTER evm.pc += Uint(1) @@ -167,9 +171,10 @@ def mcopy(evm: Evm) -> None: evm.memory, [(source, length), (destination, length)] ) charge_gas(evm, GAS_VERY_LOW + copy_gas_cost + extend_memory.cost) + update_memory_high_watermark(evm, extend_memory) # OPERATION - evm.memory += b"\x00" * extend_memory.expand_by + evm.memory.data += b"\x00" * extend_memory.expand_by value = memory_read_bytes(evm.memory, source, length) memory_write(evm.memory, destination, value) diff --git a/src/ethereum/forks/monad_next/vm/instructions/system.py b/src/ethereum/forks/monad_next/vm/instructions/system.py index a53cb06a8b..c7584f0af7 100644 --- a/src/ethereum/forks/monad_next/vm/instructions/system.py +++ b/src/ethereum/forks/monad_next/vm/instructions/system.py @@ -59,6 +59,7 @@ charge_gas, init_code_cost, max_message_call_gas, + update_memory_high_watermark, ) from ..memory import memory_read_bytes, memory_write from ..stack import pop, push @@ -175,9 +176,10 @@ def create(evm: Evm) -> None: init_code_gas = init_code_cost(Uint(memory_size)) charge_gas(evm, GAS_CREATE + extend_memory.cost + init_code_gas) + update_memory_high_watermark(evm, extend_memory) # OPERATION - evm.memory += b"\x00" * extend_memory.expand_by + evm.memory.data += b"\x00" * extend_memory.expand_by contract_address = compute_contract_address( evm.message.current_target, get_account( @@ -229,9 +231,10 @@ def create2(evm: Evm) -> None: + extend_memory.cost + init_code_gas, ) + update_memory_high_watermark(evm, extend_memory) # OPERATION - evm.memory += b"\x00" * extend_memory.expand_by + evm.memory.data += b"\x00" * extend_memory.expand_by contract_address = compute_create2_contract_address( evm.message.current_target, salt, @@ -270,9 +273,10 @@ def return_(evm: Evm) -> None: ) charge_gas(evm, GAS_ZERO + extend_memory.cost) + update_memory_high_watermark(evm, extend_memory) # OPERATION - evm.memory += b"\x00" * extend_memory.expand_by + evm.memory.data += b"\x00" * extend_memory.expand_by evm.output = memory_read_bytes( evm.memory, memory_start_position, memory_size ) @@ -410,10 +414,18 @@ def call(evm: Evm) -> None: extend_memory.cost, access_gas_cost + create_gas_cost + transfer_gas_cost, ) - charge_gas(evm, message_call_gas.cost + extend_memory.cost) + charge_gas( + evm, + access_gas_cost + + create_gas_cost + + transfer_gas_cost + + extend_memory.cost, + ) + update_memory_high_watermark(evm, extend_memory) + charge_gas(evm, message_call_gas.cost) if evm.message.is_static and value != U256(0): raise WriteInStaticContext - evm.memory += b"\x00" * extend_memory.expand_by + evm.memory.data += b"\x00" * extend_memory.expand_by sender_balance = get_account( evm.message.block_env.state, evm.message.current_target ).balance @@ -497,10 +509,12 @@ def callcode(evm: Evm) -> None: extend_memory.cost, access_gas_cost + transfer_gas_cost, ) - charge_gas(evm, message_call_gas.cost + extend_memory.cost) + charge_gas(evm, access_gas_cost + transfer_gas_cost + extend_memory.cost) + update_memory_high_watermark(evm, extend_memory) + charge_gas(evm, message_call_gas.cost) # OPERATION - evm.memory += b"\x00" * extend_memory.expand_by + evm.memory.data += b"\x00" * extend_memory.expand_by sender_balance = get_account( evm.message.block_env.state, evm.message.current_target ).balance @@ -635,10 +649,12 @@ def delegatecall(evm: Evm) -> None: message_call_gas = calculate_message_call_gas( U256(0), gas, Uint(evm.gas_left), extend_memory.cost, access_gas_cost ) - charge_gas(evm, message_call_gas.cost + extend_memory.cost) + charge_gas(evm, access_gas_cost + extend_memory.cost) + update_memory_high_watermark(evm, extend_memory) + charge_gas(evm, message_call_gas.cost) # OPERATION - evm.memory += b"\x00" * extend_memory.expand_by + evm.memory.data += b"\x00" * extend_memory.expand_by generic_call( evm, message_call_gas.sub_call, @@ -711,10 +727,12 @@ def staticcall(evm: Evm) -> None: extend_memory.cost, access_gas_cost, ) - charge_gas(evm, message_call_gas.cost + extend_memory.cost) + charge_gas(evm, access_gas_cost + extend_memory.cost) + update_memory_high_watermark(evm, extend_memory) + charge_gas(evm, message_call_gas.cost) # OPERATION - evm.memory += b"\x00" * extend_memory.expand_by + evm.memory.data += b"\x00" * extend_memory.expand_by generic_call( evm, message_call_gas.sub_call, @@ -758,9 +776,10 @@ def revert(evm: Evm) -> None: ) charge_gas(evm, extend_memory.cost) + update_memory_high_watermark(evm, extend_memory) # OPERATION - evm.memory += b"\x00" * extend_memory.expand_by + evm.memory.data += b"\x00" * extend_memory.expand_by output = memory_read_bytes(evm.memory, memory_start_index, size) evm.output = Bytes(output) raise Revert diff --git a/src/ethereum/forks/monad_next/vm/interpreter.py b/src/ethereum/forks/monad_next/vm/interpreter.py index 9cc980ba44..512f766eae 100644 --- a/src/ethereum/forks/monad_next/vm/interpreter.py +++ b/src/ethereum/forks/monad_next/vm/interpreter.py @@ -18,6 +18,7 @@ from ethereum_types.numeric import U256, Uint, ulen from ethereum.exceptions import EthereumException +from ethereum.forks.monad_next.vm.memory import EvmMemory from ethereum.trace import ( EvmStop, OpEnd, @@ -62,6 +63,7 @@ InvalidOpcode, OutOfGasError, Revert, + RevertOnOOM, RevertOnReserveBalance, StackDepthLimitError, ) @@ -250,10 +252,19 @@ def process_message(message: Message) -> Evm: transient_storage = message.tx_env.transient_storage code = message.code valid_jump_destinations = get_valid_jump_destinations(code) + + parent_high_watermark = ( + message.parent_evm.memory.high_watermark_bytes + if message.parent_evm is not None + else 0 + ) + evm = Evm( pc=Uint(0), stack=[], - memory=bytearray(), + memory=EvmMemory( + data=bytearray(), high_watermark_bytes=parent_high_watermark + ), code=code, gas_left=message.gas, valid_jump_destinations=valid_jump_destinations, @@ -304,6 +315,9 @@ def process_message(message: Message) -> Evm: except Revert as error: evm_trace(evm, OpException(error)) evm.error = error + except RevertOnOOM as error: + evm_trace(evm, OpException(error)) + evm.error = error if evm.error: # revert state to the last saved checkpoint diff --git a/src/ethereum/forks/monad_next/vm/memory.py b/src/ethereum/forks/monad_next/vm/memory.py index 3b76b2454c..6ebeb20566 100644 --- a/src/ethereum/forks/monad_next/vm/memory.py +++ b/src/ethereum/forks/monad_next/vm/memory.py @@ -16,9 +16,11 @@ from ethereum.utils.byte import right_pad_zero_bytes +from . import EvmMemory + def memory_write( - memory: bytearray, start_position: U256, value: Bytes + memory: EvmMemory, start_position: U256, value: Bytes ) -> None: """ Writes to memory. @@ -33,11 +35,11 @@ def memory_write( Data to write to memory. """ - memory[start_position : int(start_position) + len(value)] = value + memory.data[start_position : int(start_position) + len(value)] = value def memory_read_bytes( - memory: bytearray, start_position: U256, size: U256 + memory: EvmMemory, start_position: U256, size: U256 ) -> Bytes: """ Read bytes from memory. @@ -57,7 +59,9 @@ def memory_read_bytes( Data read from memory. """ - return Bytes(memory[start_position : Uint(start_position) + Uint(size)]) + return Bytes( + memory.data[start_position : Uint(start_position) + Uint(size)] + ) def buffer_read(buffer: Bytes, start_position: U256, size: U256) -> Bytes: diff --git a/src/ethereum_spec_tools/evm_tools/t8n/evm_trace/eip3155.py b/src/ethereum_spec_tools/evm_tools/t8n/evm_trace/eip3155.py index 9e89598532..281a9a686f 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/evm_trace/eip3155.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/evm_trace/eip3155.py @@ -124,7 +124,11 @@ def __call__(self, evm: Any, event: TraceEvent) -> None: refund_counter += parent_evm.refund_counter parent_evm = parent_evm.message.parent_evm - len_memory = len(evm.memory) + evm_memory_data = ( + evm.memory.data if hasattr(evm.memory, "data") else evm.memory + ) + + len_memory = len(evm_memory_data) return_data = None if isinstance(evm, EvmWithReturnData) and self.trace_return_data: @@ -132,7 +136,7 @@ def __call__(self, evm: Any, event: TraceEvent) -> None: memory = None if self.trace_memory and len_memory > 0: - memory = "0x" + evm.memory.hex() + memory = "0x" + evm_memory_data.hex() stack = None if self.trace_stack: diff --git a/tests/monad_nine/mip3_linear_memory/__init__.py b/tests/monad_nine/mip3_linear_memory/__init__.py new file mode 100644 index 0000000000..737f5be5bc --- /dev/null +++ b/tests/monad_nine/mip3_linear_memory/__init__.py @@ -0,0 +1 @@ +"""Cross-client MIP-3 Tests of Monad EVM's linear memory model.""" diff --git a/tests/monad_nine/mip3_linear_memory/helpers.py b/tests/monad_nine/mip3_linear_memory/helpers.py new file mode 100644 index 0000000000..f7761e1e94 --- /dev/null +++ b/tests/monad_nine/mip3_linear_memory/helpers.py @@ -0,0 +1,142 @@ +""" +Helper types, functions and classes for testing reserve balance. +""" + +from execution_testing import ( + Bytecode, + Op, +) +from execution_testing.forks.helpers import Fork +from execution_testing.vm import Opcode + +from .spec import Spec + + +def prepare_stack_memory_opcode(opcode: Opcode, size: int) -> Bytecode: + """Prepare valid stack for memory-allocating opcode.""" + if opcode == Op.CALLDATACOPY: + # stack: destOffset, offset, size + return Op.PUSH32(size) + Op.PUSH0 + Op.PUSH0 + elif opcode == Op.CODECOPY: + # stack: destOffset, offset, size + return Op.PUSH32(size) + Op.PUSH0 + Op.PUSH0 + elif opcode == Op.EXTCODECOPY: + # stack: address, destOffset, offset, size + address = 0x1234567890ABCDEF1234567890ABCDEF12345678 + return Op.PUSH32(size) + Op.PUSH0 + Op.PUSH0 + Op.PUSH20(address) + elif opcode == Op.MCOPY: + # stack: srcOffset, destOffset, size + return Op.PUSH32(size) + Op.PUSH0 + Op.PUSH0 + # FIXME: this goes out of bounds, no way to setup return buffer easily + # elif opcode == Op.RETURNDATACOPY: + # # stack: destOffset, offset, size + # return Op.PUSH32(size) + Op.PUSH0 + Op.PUSH0 + elif opcode == Op.SHA3: + # stack: offset, size + return Op.PUSH32(size) + Op.PUSH0 + elif opcode in (Op.LOG0, Op.LOG1, Op.LOG2, Op.LOG3, Op.LOG4): + # stack: offset, size, [topics...] + # Prepare stack with topics based on opcode + num_topics = opcode.int() - Op.LOG0.int() # Extract N from LOGn + topics_code = Bytecode() + for i in range(num_topics): + topics_code += Op.PUSH32(i + 1) + return topics_code + Op.PUSH32(size) + Op.PUSH0 + elif opcode in (Op.RETURN, Op.REVERT): + # stack: offset, size + return Op.PUSH32(size) + Op.PUSH0 + elif opcode == Op.CREATE: + # stack: value, offset, size + return Op.PUSH32(size) + Op.PUSH0 + Op.PUSH0 + elif opcode == Op.CREATE2: + # stack: value, offset, size, salt + # Use counter-based salt to avoid address conflicts + slot_counter = 0x0 + code_increment_counter = ( + Op.TLOAD(slot_counter) + + Op.DUP1 + + Op.TSTORE(slot_counter, Op.PUSH1(1) + Op.ADD) + ) + return code_increment_counter + Op.PUSH32(size) + Op.PUSH0 + Op.PUSH0 + elif opcode == Op.MSTORE: + # stack: offset, value + offset = max(0, size - 32) if size > 0 else 0 + return Op.PUSH1(0xFF) + Op.PUSH32(offset) + elif opcode == Op.MSTORE8: + # stack: offset, value + offset = max(0, size - 1) if size > 0 else 0 + return Op.PUSH1(0xFF) + Op.PUSH32(offset) + elif opcode == Op.MLOAD: + # stack: offset + offset = max(0, size - 32) if size > 0 else 0 + return Op.PUSH32(offset) + elif opcode == Op.CALL: + # stack: gas, address, value, argsOffset, argsSize, retOffset, retSize + address = 0x1234567890ABCDEF1234567890ABCDEF12345678 + return ( + Op.PUSH32(size) + + Op.PUSH0 # retSize, retOffset + + Op.PUSH0 + + Op.PUSH0 # argsSize, argsOffset + + Op.PUSH0 # value + + Op.PUSH20(address) + + Op.GAS # use all available gas + ) + elif opcode == Op.CALLCODE: + # stack: gas, address, value, argsOffset, argsSize, retOffset, retSize + address = 0x1234567890ABCDEF1234567890ABCDEF12345678 + return ( + Op.PUSH32(size) + + Op.PUSH0 # retSize, retOffset + + Op.PUSH0 + + Op.PUSH0 # argsSize, argsOffset + + Op.PUSH0 # value + + Op.PUSH20(address) + + Op.GAS # use all available gas + ) + elif opcode == Op.DELEGATECALL: + # stack: gas, address, argsOffset, argsSize, retOffset, retSize + address = 0x1234567890ABCDEF1234567890ABCDEF12345678 + return ( + Op.PUSH32(size) + + Op.PUSH0 # retSize, retOffset + + Op.PUSH0 + + Op.PUSH0 # argsSize, argsOffset + + Op.PUSH20(address) + + Op.GAS # use all available gas + ) + elif opcode == Op.STATICCALL: + # stack: gas, address, argsOffset, argsSize, retOffset, retSize + address = 0x1234567890ABCDEF1234567890ABCDEF12345678 + return ( + Op.PUSH32(size) + + Op.PUSH0 # retSize, retOffset + + Op.PUSH0 + + Op.PUSH0 # argsSize, argsOffset + + Op.PUSH20(address) + + Op.GAS # use all available gas + ) + else: + raise ValueError(f"Unknown memory opcode: {opcode}") + + +def generous_gas(fork: Fork) -> int: + """ + Return generous parametrized gas to always be enough. + """ + constant = 100_000 + gas_costs = fork.gas_costs() + sstore_cost = gas_costs.G_STORAGE_SET + gas_costs.G_COLD_SLOAD + deploy_cost = gas_costs.G_CODE_DEPOSIT_BYTE * len(Op.STOP) + access_cost = gas_costs.G_COLD_ACCOUNT_ACCESS + # Assume 5 memory expansions to the max size + linear_memory_expansion_cost = 5 * fork.memory_expansion_gas_calculator()( + new_bytes=Spec.MAX_TX_MEMORY_USAGE + ) + return ( + constant + + sstore_cost + + deploy_cost + + 5 * access_cost + + linear_memory_expansion_cost + ) diff --git a/tests/monad_nine/mip3_linear_memory/spec.py b/tests/monad_nine/mip3_linear_memory/spec.py new file mode 100644 index 0000000000..6f0be0291d --- /dev/null +++ b/tests/monad_nine/mip3_linear_memory/spec.py @@ -0,0 +1,26 @@ +"""Defines reserve balance specification constants and functions.""" + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ReferenceSpec: + """Defines the reference spec version and git path.""" + + git_path: str + version: str + + +ref_spec_3 = ReferenceSpec( + "MIPS/MIP-3.md", "fa43faa1bf86ea86a644cd6dfef7c6f2b0b8858e" +) + + +@dataclass(frozen=True) +class Spec: + """ + Parameters from the linear memory specifications as defined at MIP-3 + ........ + """ + + MAX_TX_MEMORY_USAGE = 8 * 1024 * 1024 # 8 MiB diff --git a/tests/monad_nine/mip3_linear_memory/test_gas_cost.py b/tests/monad_nine/mip3_linear_memory/test_gas_cost.py new file mode 100644 index 0000000000..db1e3d62f9 --- /dev/null +++ b/tests/monad_nine/mip3_linear_memory/test_gas_cost.py @@ -0,0 +1,325 @@ +""" +Tests linear gas cost of the MIP-3 memory model. +""" + +from typing import Generator + +import pytest +from execution_testing import ( + Account, + Alloc, + Op, + ParameterSet, + StateTestFiller, + Transaction, + gas_test, +) +from execution_testing.forks.forks.forks import MONAD_NEXT +from execution_testing.forks.helpers import Fork +from execution_testing.vm import Opcode + +from .helpers import prepare_stack_memory_opcode +from .spec import Spec, ref_spec_3 + +REFERENCE_SPEC_GIT_PATH = ref_spec_3.git_path +REFERENCE_SPEC_VERSION = ref_spec_3.version + +slot_code_worked = 0x1 +value_code_worked = 0x1234 + +pytestmark = [ + pytest.mark.valid_from("MONAD_EIGHT"), + pytest.mark.pre_alloc_group( + "mip3_tests", + reason="Tests linear memory MIP-3", + ), +] + + +@pytest.mark.parametrize("fail", [True, False]) +def test_cost_non_quadratic( + state_test: StateTestFiller, + pre: Alloc, + fail: bool, + fork: Fork, +) -> None: + """ + Simplest smoke test for checking if memory isn't quadratic cost anymore. + """ + contract = Op.MLOAD( + Spec.MAX_TX_MEMORY_USAGE - (0 if fail else 32) + ) + Op.SSTORE(slot_code_worked, value_code_worked) + contract_address = pre.deploy_contract(contract) + + tx = Transaction( + gas_limit=fork.gas_costs().G_MEMORY * Spec.MAX_TX_MEMORY_USAGE // 32 + + 100_000, + to=contract_address, + sender=pre.fund_eoa(), + ) + storage = ( + {slot_code_worked: value_code_worked} + if not fail and fork >= MONAD_NEXT + else {} + ) + + state_test( + pre=pre, + post={contract_address: Account(storage=storage)}, + tx=tx, + ) + + +def memory_copy_opcodes( + fork: Fork, +) -> Generator[ParameterSet, None, None]: + """ + Memory-reading opcodes which allocate memory. + Includes copy, hashing, and logging opcodes. + """ + valid_opcodes = set(fork.valid_opcodes()) + gas_costs = fork.gas_costs() + + memory_opcodes = { + Op.CALLDATACOPY: gas_costs.G_VERY_LOW, + Op.CODECOPY: gas_costs.G_VERY_LOW, + Op.EXTCODECOPY: gas_costs.G_WARM_ACCOUNT_ACCESS, + Op.MCOPY: gas_costs.G_VERY_LOW, + Op.SHA3: gas_costs.G_KECCAK_256, + Op.LOG0: gas_costs.G_LOG, + Op.LOG1: gas_costs.G_LOG + gas_costs.G_LOG_TOPIC, + Op.LOG2: gas_costs.G_LOG + 2 * gas_costs.G_LOG_TOPIC, + Op.LOG3: gas_costs.G_LOG + 3 * gas_costs.G_LOG_TOPIC, + Op.LOG4: gas_costs.G_LOG + 4 * gas_costs.G_LOG_TOPIC, + Op.RETURN: 0, + Op.REVERT: 0, + Op.CREATE: gas_costs.G_CREATE, + Op.CREATE2: gas_costs.G_CREATE, + Op.CALL: gas_costs.G_WARM_ACCOUNT_ACCESS, + Op.DELEGATECALL: gas_costs.G_WARM_ACCOUNT_ACCESS, + Op.STATICCALL: gas_costs.G_WARM_ACCOUNT_ACCESS, + Op.CALLCODE: gas_costs.G_WARM_ACCOUNT_ACCESS, + # FIXME: this goes out of bounds, no way to setup return buffer easily + # Op.RETURNDATACOPY: gas_costs.G_VERY_LOW, + } + + cold_access_opcodes = ( + Op.EXTCODECOPY, + Op.CALL, + Op.DELEGATECALL, + Op.STATICCALL, + Op.CALLCODE, + ) + + for opcode, base_gas in memory_opcodes.items(): + if opcode not in valid_opcodes: + continue + cold_gas = base_gas + if opcode in cold_access_opcodes: + cold_gas = gas_costs.G_COLD_ACCOUNT_ACCESS + yield opcode, base_gas, cold_gas + + +def memory_stack_opcodes( + fork: Fork, +) -> Generator[ParameterSet, None, None]: + """ + Stack-memory opcodes which always read at least 1 byte or 1 word. + """ + valid_opcodes = set(fork.valid_opcodes()) + gas_costs = fork.gas_costs() + + memory_opcodes = { + Op.MLOAD: gas_costs.G_VERY_LOW, + Op.MSTORE: gas_costs.G_VERY_LOW, + Op.MSTORE8: gas_costs.G_VERY_LOW, + } + + for opcode, base_gas in memory_opcodes.items(): + if opcode not in valid_opcodes: + continue + cold_gas = base_gas + yield pytest.param(opcode, base_gas, cold_gas, id=f"{opcode}") + + +def memory_sizes( + fork: Fork, +) -> Generator[ParameterSet, None, None]: + """ + Memory sizes to allocate up to during testing. + """ + yield pytest.param(0x00, id="zero_bytes") + yield pytest.param(0x01, id="single_byte") + yield pytest.param(0x20, id="single_word") + yield pytest.param(0x100, id="large_copy") + yield pytest.param(0x2000, id="above_quadratic_threshold_copy") + if fork >= MONAD_NEXT: + yield pytest.param(Spec.MAX_TX_MEMORY_USAGE, id="max") + + +def memory_copy_opcodes_with_size( + fork: Fork, +) -> Generator[ParameterSet, None, None]: + """ + Memory-reading opcodes with appropriate size ranges. + + LOGn opcodes exclude the "max" size parameter, because its high per-byte + cost doesn't fit in transaction gas limits. CREATEn opcodes exclude the + "max" size parameter, because that exceeds max initcode size. + """ + exclude_max_opcodes = [ + Op.LOG0, + Op.LOG1, + Op.LOG2, + Op.LOG3, + Op.LOG4, + Op.CREATE, + Op.CREATE2, + ] + + for opcode, warm_gas, cold_gas in memory_copy_opcodes(fork): + for size_param in memory_sizes(fork): + if opcode in exclude_max_opcodes and size_param.id == "max": + continue + yield pytest.param( + opcode, + warm_gas, + cold_gas, + size_param.values[0], + id=f"{opcode}-{size_param.id}", + ) + + +@pytest.mark.parametrize_by_fork( + "opcode,warm_gas,cold_gas,size", memory_copy_opcodes_with_size +) +@pytest.mark.parametrize( + "initial_memory", + [ + bytes(range(0x00, 0x100)), + bytes(), + ], + ids=["from_existent_memory", "from_empty_memory"], +) +def test_memory_copy_opcodes( + state_test: StateTestFiller, + pre: Alloc, + opcode: Opcode, + fork: Fork, + warm_gas: int, + cold_gas: int, + size: int, + initial_memory: bytes, +) -> None: + """ + Test that memory-reading opcodes consume correct gas under MIP-3. + + Verifies that memory-reading opcodes (CALLDATACOPY, CODECOPY, EXTCODECOPY, + MCOPY, SHA3, LOG0-LOG4, RETURN, REVERT, CREATE, CREATE2, CALL, + DELEGATECALL, STATICCALL, CALLCODE) use linear gas costs for memory + expansion instead of quadratic costs. + + `initial_memory` is the memory allocated before the measured opcode + extends it. + """ + cost_memory_bytes = fork.memory_expansion_gas_calculator() + + memory_expansion_cost = cost_memory_bytes( + new_bytes=size, + previous_bytes=len(initial_memory), + ) + + if opcode in ( + Op.CALLDATACOPY, + Op.CODECOPY, + Op.EXTCODECOPY, + Op.MCOPY, + Op.RETURNDATACOPY, + ): + dynamic_gas_cost = fork.gas_costs().G_COPY * ((size + 31) // 32) + if opcode == Op.SHA3: + dynamic_gas_cost = fork.gas_costs().G_KECCAK_256_WORD * ( + (size + 31) // 32 + ) + if opcode in (Op.LOG0, Op.LOG1, Op.LOG2, Op.LOG3, Op.LOG4): + dynamic_gas_cost = fork.gas_costs().G_LOG_DATA * size + if opcode in (Op.RETURN, Op.REVERT): + dynamic_gas_cost = 0 + if opcode in (Op.CREATE, Op.CREATE2): + init_code_cost = fork.gas_costs().G_INITCODE_WORD * ((size + 31) // 32) + if opcode == Op.CREATE2: + hash_cost = fork.gas_costs().G_KECCAK_256_WORD * ( + (size + 31) // 32 + ) + dynamic_gas_cost = init_code_cost + hash_cost + else: + dynamic_gas_cost = init_code_cost + if opcode in (Op.CALL, Op.DELEGATECALL, Op.STATICCALL, Op.CALLCODE): + dynamic_gas_cost = 0 + + setup_code = Op.CALLDATACOPY( + 0x00, 0x00, len(initial_memory) + ) + prepare_stack_memory_opcode(opcode, size) + + gas_test( + fork=fork, + state_test=state_test, + pre=pre, + setup_code=setup_code, + subject_code=opcode, + tear_down_code=Op.STOP, + cold_gas=cold_gas + dynamic_gas_cost + memory_expansion_cost, + warm_gas=warm_gas + dynamic_gas_cost + memory_expansion_cost, + # OOG testing depends on CALL status code, doesn't work with REVERT. + out_of_gas_testing=False if opcode == Op.REVERT else True, + ) + + +@pytest.mark.parametrize_by_fork( + "opcode,warm_gas,cold_gas", memory_stack_opcodes +) +@pytest.mark.parametrize_by_fork("size", memory_sizes) +@pytest.mark.parametrize( + "initial_memory", + [bytes(range(0x00, 0x100)), bytes(range(0x00, 0x20))], + ids=["from_existent_memory", "from_minimal_nonextendable_memory"], +) +def test_memory_stack_opcodes( + state_test: StateTestFiller, + pre: Alloc, + opcode: Opcode, + fork: Fork, + warm_gas: int, + cold_gas: int, + size: int, + initial_memory: bytes, +) -> None: + """ + Test that stack-memory opcodes consume correct gas under MIP-3. + + Verifies that stack-memory opcodes (MLOAD, MSTORE, MSTORE8) use linear gas + costs for memory expansion instead of quadratic costs. + + `initial_memory` is the memory allocated before the measured opcode + extends it. + """ + cost_memory_bytes = fork.memory_expansion_gas_calculator() + + memory_expansion_cost = cost_memory_bytes( + new_bytes=size, previous_bytes=len(initial_memory) + ) + + setup_code = Op.CALLDATACOPY( + 0x00, 0x00, len(initial_memory) + ) + prepare_stack_memory_opcode(opcode, size) + + gas_test( + fork=fork, + state_test=state_test, + pre=pre, + setup_code=setup_code, + subject_code=opcode, + tear_down_code=Op.STOP, + cold_gas=cold_gas + memory_expansion_cost, + warm_gas=warm_gas + memory_expansion_cost, + ) diff --git a/tests/monad_nine/mip3_linear_memory/test_oom.py b/tests/monad_nine/mip3_linear_memory/test_oom.py new file mode 100644 index 0000000000..c52b26cf3b --- /dev/null +++ b/tests/monad_nine/mip3_linear_memory/test_oom.py @@ -0,0 +1,1179 @@ +""" +Tests OOM (out-of-memory) behavior of the MIP-3 memory model. +""" + +import itertools + +import pytest +from execution_testing import ( + Account, + Alloc, + Op, + StateTestFiller, + Transaction, +) +from execution_testing.base_types.base_types import Address +from execution_testing.forks.helpers import Fork +from execution_testing.test_types.helpers import compute_create_address +from execution_testing.vm import Opcode + +from .helpers import generous_gas, prepare_stack_memory_opcode +from .spec import Spec, ref_spec_3 + +REFERENCE_SPEC_GIT_PATH = ref_spec_3.git_path +REFERENCE_SPEC_VERSION = ref_spec_3.version + +_slot = itertools.count(1) +slot_code_worked = next(_slot) +slot_call_result = next(_slot) +slot_inner_worked = next(_slot) +slot_all_gas_consumed = next(_slot) +slot_returndata_size_before = next(_slot) +slot_returndata_size_after = next(_slot) +slot_outer_memory_preserved = next(_slot) +slot_msize_before_call = next(_slot) +slot_msize_after_call = next(_slot) +slot_outer_msize = next(_slot) +slot_inner_msize_before = next(_slot) +slot_inner_msize_after = next(_slot) + +value_code_worked = 0x1234 +value_returndata_magic = 0xDEADBEEF +outer_memory_offset = 0x100 +outer_marker_value = 0xDEADBEEF + +pytestmark = [ + pytest.mark.valid_from("MONAD_NEXT"), + pytest.mark.pre_alloc_group( + "mip3_tests", + reason="Tests linear memory MIP-3", + ), +] + + +@pytest.mark.parametrize( + "offset", + [ + pytest.param(Spec.MAX_TX_MEMORY_USAGE - 32, id="at_limit"), + pytest.param(Spec.MAX_TX_MEMORY_USAGE - 31, id="exceed_by_1_byte"), + pytest.param(Spec.MAX_TX_MEMORY_USAGE - 1, id="exceed_by_31_bytes"), + pytest.param(Spec.MAX_TX_MEMORY_USAGE, id="exceed_by_1_word"), + pytest.param(Spec.MAX_TX_MEMORY_USAGE + 1, id="exceed_by_33_bytes"), + pytest.param(Spec.MAX_TX_MEMORY_USAGE + 1024 - 32, id="exceed_by_1KB"), + pytest.param(2 * Spec.MAX_TX_MEMORY_USAGE - 32, id="exceed_by_8MB"), + pytest.param(2**256 - 1, id="exceed_by_max"), + ], +) +def test_top_level_oom( + state_test: StateTestFiller, + pre: Alloc, + offset: int, + fork: Fork, +) -> None: + """ + Test that exceeding the 8 MiB memory limit causes the call to revert. + """ + exceed = offset + 32 > Spec.MAX_TX_MEMORY_USAGE + + contract = Op.MLOAD(offset) + Op.SSTORE( + slot_code_worked, value_code_worked + ) + contract_address = pre.deploy_contract(contract) + + tx = Transaction( + gas_limit=generous_gas(fork), + to=contract_address, + sender=pre.fund_eoa(), + ) + + storage = {} if exceed else {slot_code_worked: value_code_worked} + + state_test( + pre=pre, + post={contract_address: Account(storage=storage)}, + tx=tx, + ) + + +@pytest.mark.parametrize("exceed", [True, False]) +def test_top_level_oom_value_transfer( + state_test: StateTestFiller, + pre: Alloc, + exceed: bool, + fork: Fork, +) -> None: + """ + Test that OOM reverts the call but gas is still charged. + Value transfer only happens if the call succeeds. + """ + offset = ( + Spec.MAX_TX_MEMORY_USAGE if exceed else Spec.MAX_TX_MEMORY_USAGE - 32 + ) + + contract = Op.MLOAD(offset) + Op.SSTORE( + slot_code_worked, value_code_worked + ) + contract_address = pre.deploy_contract(contract) + + gas_limit = generous_gas(fork) + gas_price = 10 + tx_value = 10**18 + gas_cost = gas_limit * gas_price + initial_balance = gas_cost + 2 * tx_value + + sender = pre.fund_eoa(initial_balance) + + tx = Transaction( + gas_limit=gas_limit, + max_fee_per_gas=gas_price, + max_priority_fee_per_gas=gas_price, + to=contract_address, + value=tx_value, + sender=sender, + ) + + storage = {} if exceed else {slot_code_worked: value_code_worked} + contract_balance = 0 if exceed else tx_value + sender_balance = ( + initial_balance - gas_cost + if exceed + else initial_balance - gas_cost - tx_value + ) + + state_test( + pre=pre, + post={ + contract_address: Account( + storage=storage, balance=contract_balance + ), + sender: Account(balance=sender_balance), + }, + tx=tx, + ) + + +@pytest.mark.parametrize("exceed", [True, False]) +@pytest.mark.with_all_call_opcodes +def test_nested_call_oom( + state_test: StateTestFiller, + pre: Alloc, + exceed: bool, + call_opcode: Op, + fork: Fork, +) -> None: + """ + Test OOM behavior in a nested call using various call opcodes. + """ + offset = ( + Spec.MAX_TX_MEMORY_USAGE if exceed else Spec.MAX_TX_MEMORY_USAGE - 32 + ) + + if call_opcode == Op.STATICCALL: + inner_contract = Op.MLOAD(offset) + Op.STOP + else: + inner_contract = Op.MLOAD(offset) + Op.SSTORE( + slot_inner_worked, value_code_worked + ) + inner_address = pre.deploy_contract(inner_contract) + + outer_contract = Op.SSTORE( + slot_call_result, call_opcode(address=inner_address) + ) + Op.SSTORE(slot_code_worked, value_code_worked) + outer_address = pre.deploy_contract(outer_contract) + + tx = Transaction( + gas_limit=generous_gas(fork), + to=outer_address, + sender=pre.fund_eoa(), + ) + + outer_storage = { + slot_code_worked: value_code_worked, + slot_call_result: 0 if exceed else 1, + } + if call_opcode in (Op.DELEGATECALL, Op.CALLCODE) and not exceed: + outer_storage[slot_inner_worked] = value_code_worked + + inner_storage = {} + if call_opcode == Op.CALL and not exceed: + inner_storage[slot_inner_worked] = value_code_worked + + post = {outer_address: Account(storage=outer_storage)} + if inner_storage: + post[inner_address] = Account(storage=inner_storage) + + state_test( + pre=pre, + post=post, + tx=tx, + ) + + +@pytest.mark.parametrize( + "callee_code,expected_call_result,consumes_all_gas", + [ + pytest.param(Op.STOP, 1, False, id="success"), + pytest.param(Op.MLOAD(Spec.MAX_TX_MEMORY_USAGE), 0, False, id="oom"), + pytest.param(Op.REVERT(0, 0), 0, False, id="revert"), + pytest.param(Op.INVALID, 0, True, id="invalid"), + ], +) +def test_nested_call_gas_consumption( + state_test: StateTestFiller, + pre: Alloc, + callee_code: Op, + expected_call_result: int, + consumes_all_gas: bool, + fork: Fork, +) -> None: + """ + Test gas consumption behavior of CALL with different callee outcomes. + REVERT should not consume all gas, while INVALID does. + """ + inner_address = pre.deploy_contract(callee_code) + + gas_limit = generous_gas(fork) + gas_threshold = gas_limit // 64 + + # Need to preset storage slots with non-zero values in order to have + # cheaper SSTORE after the potentially OOGing Op.CALL + outer_contract = ( + Op.SSTORE(slot_code_worked, value_code_worked) + + Op.SSTORE(slot_call_result, 1) + + Op.SSTORE(slot_all_gas_consumed, 1) + + Op.SSTORE(slot_call_result, Op.CALL(address=inner_address)) + + Op.SSTORE(slot_all_gas_consumed, Op.LT(Op.GAS, gas_threshold)) + ) + outer_address = pre.deploy_contract(outer_contract) + + tx = Transaction( + gas_limit=gas_limit, + to=outer_address, + sender=pre.fund_eoa(), + ) + + state_test( + pre=pre, + post={ + outer_address: Account( + storage={ + slot_code_worked: value_code_worked, + slot_call_result: expected_call_result, + slot_all_gas_consumed: 1 if consumes_all_gas else 0, + } + ) + }, + tx=tx, + ) + + +def test_nested_call_oom_insufficient_gas( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Test OOM behavior when CALL is given insufficient gas for memory expansion. + + This tests which check comes first: gas availability or memory limit. + """ + inner_contract = Op.MLOAD(Spec.MAX_TX_MEMORY_USAGE) + inner_address = pre.deploy_contract(inner_contract) + + memory_expansion_gas = ( + fork.gas_costs().G_MEMORY * Spec.MAX_TX_MEMORY_USAGE // 32 + ) + insufficient_gas = memory_expansion_gas // 2 + gas_limit = generous_gas(fork) + + outer_contract = ( + Op.SSTORE(slot_code_worked, value_code_worked) + + Op.SSTORE( + slot_call_result, + Op.CALL(address=inner_address, gas=insufficient_gas), + ) + + Op.SSTORE( + slot_all_gas_consumed, Op.LT(Op.GAS, gas_limit - insufficient_gas) + ) + ) + outer_address = pre.deploy_contract(outer_contract) + + tx = Transaction( + gas_limit=gas_limit, + to=outer_address, + sender=pre.fund_eoa(), + ) + + state_test( + pre=pre, + post={ + outer_address: Account( + storage={ + slot_code_worked: value_code_worked, + slot_call_result: 0, + slot_all_gas_consumed: 1, + } + ) + }, + tx=tx, + ) + + +@pytest.mark.parametrize("exceed", [True, False]) +@pytest.mark.with_all_create_opcodes +def test_nested_create_oom( + state_test: StateTestFiller, + pre: Alloc, + exceed: bool, + create_opcode: Op, + fork: Fork, +) -> None: + """ + Test OOM behavior in initcode executed via CREATE/CREATE2. + """ + # We need to subtract a word extra because the outer frame allocated it + # for the initcode! + offset = ( + Spec.MAX_TX_MEMORY_USAGE - 32 + if exceed + else Spec.MAX_TX_MEMORY_USAGE - 32 - 32 + ) + + initcode = Op.MLOAD(offset) + Op.STOP + initcode_bytes = bytes(initcode) + b"\x00" * (32 - (len(initcode) % 32)) + + factory = ( + Op.MSTORE(0, Op.PUSH32(initcode_bytes)) + + Op.SSTORE(slot_call_result, create_opcode(size=len(initcode))) + + Op.SSTORE(slot_code_worked, value_code_worked) + ) + factory_address = pre.deploy_contract(factory) + + tx = Transaction( + gas_limit=generous_gas(fork), + to=factory_address, + sender=pre.fund_eoa(), + ) + + factory_storage = { + slot_code_worked: value_code_worked, + } + new_contract_address = compute_create_address( + address=factory_address, + nonce=1, + initcode=initcode, + opcode=create_opcode, + ) + if exceed: + factory_storage[slot_call_result] = 0 + new_contract = Account.NONEXISTENT + else: + factory_storage[slot_call_result] = new_contract_address + new_contract = Account(code=b"") + + state_test( + pre=pre, + post={ + factory_address: Account(storage=factory_storage), + new_contract_address: new_contract, + }, + tx=tx, + ) + + +@pytest.mark.parametrize("exceed", [True, False]) +@pytest.mark.with_all_contract_creating_tx_types +def test_top_level_oom_creation_tx( + state_test: StateTestFiller, + pre: Alloc, + exceed: bool, + tx_type: int, + fork: Fork, +) -> None: + """ + Test OOM behavior in initcode of a contract creation transaction (to=None). + """ + offset = ( + Spec.MAX_TX_MEMORY_USAGE if exceed else Spec.MAX_TX_MEMORY_USAGE - 32 + ) + + initcode = Op.MLOAD(offset) + Op.STOP + + tx = Transaction( + gas_limit=generous_gas(fork), + to=None, + ty=tx_type, + sender=pre.fund_eoa(), + input=initcode, + ) + + state_test( + pre=pre, + post={ + tx.created_contract: Account.NONEXISTENT + if exceed + else Account(code=b"") + }, + tx=tx, + ) + + +@pytest.mark.parametrize( + "opcode", + [ + Op.CALLDATACOPY, + Op.CODECOPY, + Op.EXTCODECOPY, + Op.MCOPY, + Op.SHA3, + Op.LOG0, + Op.LOG1, + Op.LOG2, + Op.LOG3, + Op.LOG4, + Op.CREATE, + Op.CREATE2, + Op.MLOAD, + Op.MSTORE, + Op.MSTORE8, + Op.CALL, + Op.DELEGATECALL, + Op.STATICCALL, + Op.CALLCODE, + ], +) +@pytest.mark.parametrize("exceed", [True, False]) +def test_all_memory_opcodes_oom( + state_test: StateTestFiller, + pre: Alloc, + opcode: Opcode, + exceed: bool, + fork: Fork, +) -> None: + """ + Test OOM behavior for all memory-allocating opcodes. + """ + # LOG opcodes have high per-byte cost, CREATE opcodes have initcode size + # limits + small_size_opcodes = ( + Op.LOG0, + Op.LOG1, + Op.LOG2, + Op.LOG3, + Op.LOG4, + Op.CREATE, + Op.CREATE2, + ) + if exceed: + size = Spec.MAX_TX_MEMORY_USAGE + 32 + elif opcode in small_size_opcodes: + size = 0x2000 + else: + size = Spec.MAX_TX_MEMORY_USAGE - 32 + + contract = ( + prepare_stack_memory_opcode(opcode, size) + + opcode + + Op.SSTORE(slot_code_worked, value_code_worked) + ) + contract_address = pre.deploy_contract(contract) + + tx = Transaction( + gas_limit=generous_gas(fork), + to=contract_address, + sender=pre.fund_eoa(), + ) + + storage = {} if exceed else {slot_code_worked: value_code_worked} + + state_test( + pre=pre, + post={contract_address: Account(storage=storage)}, + tx=tx, + ) + + +@pytest.mark.parametrize("exceeds_at_depth", [1, 2, 3, 15, 128]) +def test_nested_frames_oom( + state_test: StateTestFiller, + pre: Alloc, + exceeds_at_depth: int, + fork: Fork, +) -> None: + """ + Test OOM behavior across nested call frames. + + Each frame allocates chunk_size = MAX / exceeds_at_depth bytes. + Memory limit is checked cumulatively across all call frames. + At the deepest frame, cumulative memory exceeds MAX and causes OOM. + Each successful frame stores 1 to its depth slot. + + See test_oom_deep.py for a variant with MONAD_EIGHT comparison. + """ + slot_depth = 0x100 # Base slot for depth markers + + # Add extra to ensure cumulative total exceeds MAX at deepest level + chunk_size = (Spec.MAX_TX_MEMORY_USAGE // exceeds_at_depth) + 64 + + # Deploy contracts from deepest to shallowest + addresses = [] + for depth in range(exceeds_at_depth - 1, -1, -1): + callee = addresses[-1] if addresses else Address(0x0) + contract = ( + Op.SSTORE(slot_depth + depth, 1) + + Op.MLOAD(chunk_size - 32) + # Use DELEGATECALL so storage writes go to entry contract + + Op.DELEGATECALL(address=callee) + ) + addresses.append(pre.deploy_contract(contract)) + + entry_address = addresses[-1] + + tx = Transaction( + gas_limit=fork.transaction_gas_limit_cap() + if exceeds_at_depth > 16 + else generous_gas(fork), + to=entry_address, + sender=pre.fund_eoa(), + ) + + # Depths 0 through exceeds_at_depth-2 succeed + # Depth exceeds_at_depth-1 fails due to OOM + storage = {slot_depth + d: 1 for d in range(exceeds_at_depth - 1)} + + state_test( + pre=pre, + post={entry_address: Account(storage=storage)}, + tx=tx, + ) + + +@pytest.mark.parametrize( + "inner_exit", + [ + pytest.param(Op.MLOAD(Spec.MAX_TX_MEMORY_USAGE), id="oom"), + Op.STOP, + Op.RETURN(0, 0), + Op.REVERT(0, 0), + Op.INVALID, + Op.SELFDESTRUCT, + ], +) +def test_inner_frame_memory_wipe( + state_test: StateTestFiller, + pre: Alloc, + inner_exit: Op, + fork: Fork, +) -> None: + """ + Test that inner frame memory is wiped after call ends, outer frame memory + is preserved, and MSIZE reflects the outer frame's allocation only. + """ + inner_contract = Op.MLOAD(0) + inner_exit + inner_address = pre.deploy_contract(inner_contract) + + expected_msize = outer_memory_offset + 32 + + # Preset storage slots for cheaper SSTORE after gas-consuming CALL + outer_contract = ( + Op.SSTORE(slot_msize_after_call, 1) + + Op.SSTORE(slot_outer_memory_preserved, 1) + + Op.SSTORE(slot_code_worked, 1) + + Op.MSTORE(outer_memory_offset, outer_marker_value) + + Op.SSTORE(slot_msize_before_call, Op.MSIZE) + + Op.CALL(address=inner_address) + + Op.SSTORE(slot_msize_after_call, Op.MSIZE) + + Op.SSTORE(slot_outer_memory_preserved, Op.MLOAD(outer_memory_offset)) + + Op.SSTORE(slot_code_worked, value_code_worked) + ) + outer_address = pre.deploy_contract(outer_contract) + + tx = Transaction( + gas_limit=generous_gas(fork), + to=outer_address, + sender=pre.fund_eoa(), + ) + + storage = { + slot_code_worked: value_code_worked, + slot_msize_before_call: expected_msize, + slot_msize_after_call: expected_msize, + slot_outer_memory_preserved: outer_marker_value, + } + + state_test( + pre=pre, + post={outer_address: Account(storage=storage)}, + tx=tx, + ) + + +@pytest.mark.parametrize("oom_opcode", [Op.MLOAD, Op.RETURN, Op.REVERT]) +@pytest.mark.parametrize("exceed", [True, False]) +def test_oom_clears_returndata( + state_test: StateTestFiller, + pre: Alloc, + exceed: bool, + oom_opcode: Op, + fork: Fork, +) -> None: + """ + Test that OOM clears the returndata buffer. + + Includes also OOMing opcodes which otherwise would _set_ returndata. + """ + filling_contract_code = Op.MSTORE(0, value_returndata_magic) + Op.RETURN( + 0, 32 + ) + filling_callee_address = pre.deploy_contract(filling_contract_code) + + offset = ( + Spec.MAX_TX_MEMORY_USAGE if exceed else Spec.MAX_TX_MEMORY_USAGE - 32 + ) + + oom_callee_address = pre.deploy_contract( + oom_opcode(offset) + if oom_opcode == Op.MLOAD + else oom_opcode(offset, 32) + ) + + outer_contract = ( + Op.CALL(address=filling_callee_address) + + Op.SSTORE(slot_returndata_size_before, Op.RETURNDATASIZE) + + Op.CALL(address=oom_callee_address) + + Op.SSTORE(slot_returndata_size_after, Op.RETURNDATASIZE) + + Op.SSTORE(slot_code_worked, value_code_worked) + ) + outer_address = pre.deploy_contract(outer_contract) + + tx = Transaction( + gas_limit=generous_gas(fork), + to=outer_address, + sender=pre.fund_eoa(), + ) + + expected_returndata_after = 0 if exceed or oom_opcode == Op.MLOAD else 32 + + storage = { + slot_code_worked: value_code_worked, + slot_returndata_size_before: 32, + slot_returndata_size_after: expected_returndata_after, + } + + state_test( + pre=pre, + post={outer_address: Account(storage=storage)}, + tx=tx, + ) + + +@pytest.mark.parametrize( + "inner_exit", + [ + pytest.param(Op.MLOAD(Spec.MAX_TX_MEMORY_USAGE - 32), id="oom"), + Op.STOP, + Op.RETURN(0, 0), + Op.REVERT(0, 0), + Op.INVALID, + ], +) +@pytest.mark.with_all_create_opcodes +def test_inner_frame_memory_wipe_create( + state_test: StateTestFiller, + pre: Alloc, + inner_exit: Op, + create_opcode: Op, + fork: Fork, +) -> None: + """ + Test that CREATE/CREATE2 frame memory is wiped after initcode ends, + outer frame memory is preserved, and MSIZE reflects outer allocation only. + """ + initcode = Op.MLOAD(0) + inner_exit + initcode_bytes = bytes(initcode) + b"\x00" * (32 - (len(initcode) % 32)) + + expected_msize = outer_memory_offset + 32 + + # Preset storage slots for cheaper SSTORE after gas-consuming CREATE + outer_contract = ( + Op.SSTORE(slot_msize_after_call, 1) + + Op.SSTORE(slot_outer_memory_preserved, 1) + + Op.SSTORE(slot_code_worked, 1) + + Op.MSTORE(outer_memory_offset, outer_marker_value) + + Op.SSTORE(slot_msize_before_call, Op.MSIZE) + + Op.MSTORE(0, Op.PUSH32(initcode_bytes)) + + create_opcode(size=len(initcode)) + + Op.SSTORE(slot_msize_after_call, Op.MSIZE) + + Op.SSTORE(slot_outer_memory_preserved, Op.MLOAD(outer_memory_offset)) + + Op.SSTORE(slot_code_worked, value_code_worked) + ) + outer_address = pre.deploy_contract(outer_contract) + + tx = Transaction( + gas_limit=generous_gas(fork), + to=outer_address, + sender=pre.fund_eoa(), + ) + + storage = { + slot_code_worked: value_code_worked, + slot_msize_before_call: expected_msize, + slot_msize_after_call: expected_msize, + slot_outer_memory_preserved: outer_marker_value, + } + + state_test( + pre=pre, + post={outer_address: Account(storage=storage)}, + tx=tx, + ) + + +@pytest.mark.parametrize("exceed", [True, False]) +@pytest.mark.with_all_create_opcodes +def test_oom_clears_returndata_create( + state_test: StateTestFiller, + pre: Alloc, + exceed: bool, + create_opcode: Op, + fork: Fork, +) -> None: + """ + Test that OOM in CREATE/CREATE2 initcode clears the returndata buffer. + """ + filling_contract_code = Op.MSTORE(0, value_returndata_magic) + Op.RETURN( + 0, 32 + ) + filling_callee_address = pre.deploy_contract(filling_contract_code) + + # Subtract 32 because outer frame uses memory for initcode + offset = ( + Spec.MAX_TX_MEMORY_USAGE - 32 + if exceed + else Spec.MAX_TX_MEMORY_USAGE - 32 - 32 + ) + + initcode = Op.MLOAD(offset) + initcode_bytes = bytes(initcode) + b"\x00" * (32 - (len(initcode) % 32)) + + outer_contract = ( + Op.CALL(address=filling_callee_address) + + Op.SSTORE(slot_returndata_size_before, Op.RETURNDATASIZE) + + Op.MSTORE(0, Op.PUSH32(initcode_bytes)) + + create_opcode(size=len(initcode)) + + Op.SSTORE(slot_returndata_size_after, Op.RETURNDATASIZE) + + Op.SSTORE(slot_code_worked, value_code_worked) + ) + outer_address = pre.deploy_contract(outer_contract) + + tx = Transaction( + gas_limit=generous_gas(fork), + to=outer_address, + sender=pre.fund_eoa(), + ) + + storage = { + slot_code_worked: value_code_worked, + slot_returndata_size_before: 32, + slot_returndata_size_after: 0, + } + + state_test( + pre=pre, + post={outer_address: Account(storage=storage)}, + tx=tx, + ) + + +@pytest.mark.parametrize( + "outer_alloc_size,inner_alloc_size", + [ + pytest.param(0, 0, id="zero_zero"), + pytest.param(0, 1, id="zero_one"), + pytest.param(1, 0, id="one_zero"), + pytest.param(1, 1, id="single_byte_both"), + pytest.param(32, 32, id="word_both"), + pytest.param(256, 512, id="outer_smaller"), + pytest.param(512, 256, id="inner_smaller"), + pytest.param(4096, 4096, id="equal_4KB"), + pytest.param( + Spec.MAX_TX_MEMORY_USAGE // 2, + Spec.MAX_TX_MEMORY_USAGE // 2, + id="half_half", + ), + pytest.param( + Spec.MAX_TX_MEMORY_USAGE - 32, + 1, + id="almostlimit_one", + ), + pytest.param( + 1, + Spec.MAX_TX_MEMORY_USAGE - 32, + id="one_almostlimit", + ), + pytest.param(33, 33, id="one_past_word_both"), + pytest.param( + 31, 65, id="one_before_word_outer_one_past_two_words_inner" + ), + pytest.param(256, 257, id="outer_aligned_inner_unaligned"), + pytest.param(257, 256, id="outer_unaligned_inner_aligned"), + ], +) +def test_msize_across_frames( + state_test: StateTestFiller, + pre: Alloc, + outer_alloc_size: int, + inner_alloc_size: int, + fork: Fork, +) -> None: + """ + Test MSIZE behavior in outer and inner frames with different allocations. + + Uses CALLDATACOPY for precise allocation sizes. Allocations always round + up to the nearest 32-byte word boundary, which is reflected by MSIZE. + """ + inner_contract = ( + Op.SSTORE(slot_inner_msize_before, Op.MSIZE) + + Op.CALLDATACOPY(0, 0, inner_alloc_size) + + Op.SSTORE(slot_inner_msize_after, Op.MSIZE) + + Op.STOP + ) + inner_address = pre.deploy_contract(inner_contract) + + outer_contract = ( + Op.CALLDATACOPY(0, 0, outer_alloc_size) + + Op.SSTORE(slot_outer_msize, Op.MSIZE) + + Op.CALL(address=inner_address) + + Op.SSTORE(slot_msize_after_call, Op.MSIZE) + + Op.SSTORE(slot_code_worked, value_code_worked) + ) + outer_address = pre.deploy_contract(outer_contract) + + tx = Transaction( + gas_limit=generous_gas(fork), + to=outer_address, + sender=pre.fund_eoa(), + ) + + outer_msize = ((outer_alloc_size + 31) // 32) * 32 + inner_msize = ((inner_alloc_size + 31) // 32) * 32 + + outer_storage = { + slot_code_worked: value_code_worked, + slot_outer_msize: outer_msize, + slot_msize_after_call: outer_msize, + } + + inner_storage = { + slot_inner_msize_before: 0, + slot_inner_msize_after: inner_msize, + } + + state_test( + pre=pre, + post={ + outer_address: Account(storage=outer_storage), + inner_address: Account(storage=inner_storage), + }, + tx=tx, + ) + + +@pytest.mark.parametrize( + "outer_alloc_size,inner_alloc_size", + [ + pytest.param( + Spec.MAX_TX_MEMORY_USAGE // 2 + 1, + Spec.MAX_TX_MEMORY_USAGE // 2 - 31, + id="halfplus_halfminus", + ), + pytest.param( + Spec.MAX_TX_MEMORY_USAGE // 2 - 31, + Spec.MAX_TX_MEMORY_USAGE // 2 + 1, + id="halfminus_halfplus", + ), + pytest.param( + 1, + Spec.MAX_TX_MEMORY_USAGE - 31, + id="one_limitminus", + ), + pytest.param( + Spec.MAX_TX_MEMORY_USAGE - 31, + 1, + id="limitminus_one", + ), + ], +) +def test_memory_word_rounding_at_limit( + state_test: StateTestFiller, + pre: Alloc, + outer_alloc_size: int, + inner_alloc_size: int, + fork: Fork, +) -> None: + """ + Test that memory usage is rounded up to the nearest 32-byte word. + + Inner and outer frames technically allocate less than limit, but + due to rounding limit is exceeded. + """ + assert outer_alloc_size + inner_alloc_size <= Spec.MAX_TX_MEMORY_USAGE + + inner_contract = Op.CALLDATACOPY(0, 0, inner_alloc_size) + Op.STOP + inner_address = pre.deploy_contract(inner_contract) + + outer_contract = ( + Op.CALLDATACOPY(0, 0, outer_alloc_size) + + Op.SSTORE(slot_call_result, Op.CALL(address=inner_address)) + + Op.SSTORE(slot_code_worked, value_code_worked) + ) + outer_address = pre.deploy_contract(outer_contract) + + tx = Transaction( + gas_limit=generous_gas(fork), + to=outer_address, + sender=pre.fund_eoa(), + ) + + # Memory is rounded to nearest word, so inner allocation fails + storage = { + slot_code_worked: value_code_worked, + slot_call_result: 0, + } + + state_test( + pre=pre, + post={outer_address: Account(storage=storage)}, + tx=tx, + ) + + +@pytest.mark.parametrize("exceed", [True, False]) +@pytest.mark.parametrize( + "scenario", + [ + pytest.param("static_violation"), + pytest.param("oog_value_transfer"), + pytest.param("oog_access_cost"), + pytest.param("insufficient_balance"), + ], +) +def test_oom_check_ordering_in_call( + state_test: StateTestFiller, + pre: Alloc, + scenario: str, + exceed: bool, + fork: Fork, +) -> None: + """ + Test OOM check placement relative to other CALL checks. + + CALL check order in system.py: + 1. charge_gas (memory extension + access cost + value transfer cost) + 2. OOM check (update_memory_high_watermark) + 3. Static call violation check (WriteInStaticContext) + 4. Balance check (returns 0, refunds gas) + + Each scenario triggers a specific check to verify ordering relative to OOM. + The exceed parameter controls whether the memory access would cause OOM. + + NOTE: for checking the order of checks wrt. memory expansion _gas cost_ + refert to test_oom_deep.py, as it includes testing against previous fork. + """ + gas_limit = generous_gas(fork) + gas_costs = fork.gas_costs() + + offset = ( + Spec.MAX_TX_MEMORY_USAGE if exceed else Spec.MAX_TX_MEMORY_USAGE - 32 + ) + + if scenario == "static_violation": + # Test: static check happens AFTER OOM check in CALL opcode. + # Setup: Inner does CALL with value (static violation) with return + # buffer that may or may not OOM. + gas_threshold = gas_limit // 64 + + inner_contract = Op.CALL( + address=pre.empty_account(), + value=1, + ret_offset=0, + ret_size=32, + ) + inner_address = pre.deploy_contract(inner_contract, balance=10**18) + + outer_contract = ( + # Allocate almost the entire memory, so next allocation of 32 bytes + # OOMs if exceed is True. + Op.MLOAD(offset - 32) + + Op.SSTORE(slot_code_worked, value_code_worked) + + Op.SSTORE(slot_call_result, 123) + + Op.SSTORE(slot_all_gas_consumed, 123) + + Op.SSTORE(slot_call_result, Op.STATICCALL(address=inner_address)) + + Op.SSTORE(slot_all_gas_consumed, Op.LT(Op.GAS, gas_threshold)) + ) + outer_address = pre.deploy_contract(outer_contract) + + post = { + outer_address: Account( + storage={ + slot_code_worked: value_code_worked, + slot_call_result: 0, + # OOM check runs first: + slot_all_gas_consumed: 0 if exceed else 1, + } + ) + } + + elif scenario == "oog_value_transfer": + # Test: charge_gas (for value transfer cost) happens BEFORE OOM check. + warm_account = pre.empty_account() + inner_gas = ( + gas_costs.G_WARM_ACCOUNT_ACCESS + gas_costs.G_CALL_VALUE - 10 + ) + gas_threshold = gas_limit - inner_gas + + inner_contract = Op.MSTORE( + 0, + Op.CALL( + gas=0, + address=warm_account, + value=1, + ret_offset=0, + ret_size=32, + ), + ) + Op.RETURN(0, 32) + inner_address = pre.deploy_contract(inner_contract) + + outer_contract = ( + # Allocate almost the entire memory, so next allocation of 32 bytes + # OOMs if exceed is True. + Op.MLOAD(offset - 32) + + Op.BALANCE(warm_account) + + Op.SSTORE(slot_code_worked, value_code_worked) + + Op.SSTORE(slot_call_result, 123) + + Op.SSTORE(slot_all_gas_consumed, 123) + + Op.DELEGATECALL( + gas=inner_gas, + address=inner_address, + ) + + Op.SSTORE(slot_all_gas_consumed, Op.LT(Op.GAS, gas_threshold)) + + Op.RETURNDATACOPY(0, 0, Op.RETURNDATASIZE) + + Op.SSTORE(slot_call_result, Op.MLOAD(0)) + ) + outer_address = pre.deploy_contract(outer_contract, balance=10**18) + + post = { + outer_address: Account( + storage={ + slot_code_worked: value_code_worked, + slot_call_result: 0, + # oog check comes first so regardless of OOM: + slot_all_gas_consumed: 1, + } + ) + } + + elif scenario == "oog_access_cost": + # Test: charge_gas (for cold access cost) happens BEFORE OOM check. + # Setup: CALL to cold address with contract with memory access. + # Result: With exceed=True, callee OOMs. With exceed=False, succeeds. + cold_account = pre.empty_account() + inner_gas = gas_costs.G_COLD_ACCOUNT_ACCESS - 10 + gas_threshold = gas_limit - inner_gas + + inner_contract = Op.MSTORE( + 0, + Op.CALL( + gas=0, + address=cold_account, + ret_offset=0, + ret_size=32, + ), + ) + Op.RETURN(0, 32) + inner_address = pre.deploy_contract(inner_contract) + + outer_contract = ( + # Allocate almost the entire memory, so next allocation of 32 bytes + # OOMs if exceed is True. + Op.MLOAD(offset - 32) + + Op.SSTORE(slot_code_worked, value_code_worked) + + Op.SSTORE(slot_call_result, 123) + + Op.SSTORE(slot_all_gas_consumed, 123) + + Op.DELEGATECALL( + gas=inner_gas, + address=inner_address, + ) + + Op.SSTORE(slot_all_gas_consumed, Op.LT(Op.GAS, gas_threshold)) + + Op.RETURNDATACOPY(0, 0, Op.RETURNDATASIZE) + + Op.SSTORE(slot_call_result, Op.MLOAD(0)) + ) + outer_address = pre.deploy_contract(outer_contract, balance=10**18) + + post = { + outer_address: Account( + storage={ + slot_code_worked: value_code_worked, + slot_call_result: 0, + # oog check comes first so regardless of OOM: + slot_all_gas_consumed: 1, + } + ) + } + + else: # insufficient_balance + # Test: balance check happens AFTER OOM check. + # Setup: CALL with value > sender balance, ret_offset may cause OOM. + # With exceed=True, OOM check fails first (before balance check). + # With exceed=False, OOM check passes, balance check fails. + warm_account = pre.empty_account() + gas_threshold = gas_limit // 64 + + inner_contract = Op.MSTORE( + 0, + Op.CALL( + gas=0, + address=warm_account, + value=1, + ret_offset=0, + ret_size=32, + ), + ) + Op.RETURN(0, 32) + inner_address = pre.deploy_contract(inner_contract) + + outer_contract = ( + # Allocate almost the entire memory, so next allocation of 32 bytes + # OOMs if exceed is True. + Op.MLOAD(offset - 32) + + Op.BALANCE(warm_account) + + Op.SSTORE(slot_code_worked, value_code_worked) + + Op.SSTORE(slot_call_result, 123) + + Op.SSTORE(slot_all_gas_consumed, 123) + + Op.DELEGATECALL(address=inner_address) + + Op.SSTORE(slot_all_gas_consumed, Op.LT(Op.GAS, gas_threshold)) + + Op.RETURNDATACOPY(0, 0, Op.RETURNDATASIZE) + + Op.SSTORE(slot_call_result, Op.MLOAD(0)) + ) + outer_address = pre.deploy_contract(outer_contract, balance=0) + + post = { + outer_address: Account( + storage={ + slot_code_worked: value_code_worked, + slot_call_result: 0, + # in either case not all gas is consumed + slot_all_gas_consumed: 0, + } + ) + } + + tx = Transaction( + gas_limit=gas_limit, + to=outer_address, + sender=pre.fund_eoa(), + ) + + state_test( + pre=pre, + post=post, + tx=tx, + ) diff --git a/tests/monad_nine/mip3_linear_memory/test_oom_deep.py b/tests/monad_nine/mip3_linear_memory/test_oom_deep.py new file mode 100644 index 0000000000..47419d4f51 --- /dev/null +++ b/tests/monad_nine/mip3_linear_memory/test_oom_deep.py @@ -0,0 +1,100 @@ +""" +Tests deep nested call frames with memory allocation. + +Tests that run on both MONAD_EIGHT and MONAD_NEXT to compare behavior: +- MONAD_NEXT: OOM when cumulative memory exceeds 8MB limit +- MONAD_EIGHT: can go above this limit if allocations spread across frames +""" + +import pytest +from execution_testing import ( + Account, + Alloc, + Op, + StateTestFiller, + Transaction, +) +from execution_testing.forks.forks.forks import MONAD_NEXT +from execution_testing.forks.helpers import Fork + +from .spec import Spec, ref_spec_3 + +REFERENCE_SPEC_GIT_PATH = ref_spec_3.git_path +REFERENCE_SPEC_VERSION = ref_spec_3.version + +slot_depth = 0x100 + +pytestmark = [ + pytest.mark.valid_from("MONAD_EIGHT"), + pytest.mark.pre_alloc_group( + "mip3_tests", + reason="Tests linear memory MIP-3", + ), +] + + +def test_nested_frames_deep( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Test memory allocation across many nested call frames. + + Uses small chunk sizes to maximize depth. In MONAD_NEXT, reverts when + cumulative memory exceeds MAX_TX_MEMORY_USAGE. This test contrasts that + in MONAD_EIGHT one tx can allocate more memory in total and serves as + a sanity check. + """ + gas_limit = 30_000_000 + chunk_size = 128 * 1024 # 128KB per frame + + # Calculate expected max depth for each fork + # MONAD_NEXT: limited by 8MB cumulative memory = 64 frames + # MONAD_EIGHT: limited by gas (quadratic cost + 63/64 forwarding) + # ~150 frames + max_depth = ( + 100 # should successfully allocate at all depts for MONAD_EIGHT + ) + + # Deploy contracts from deepest to shallowest + addresses = [] + for depth in range(max_depth - 1, -1, -1): + if depth == max_depth - 1: + # Deepest level: allocate memory and store success + contract = Op.MLOAD(chunk_size - 32) + Op.SSTORE( + slot_depth + depth, 1 + ) + else: + callee = addresses[-1] + contract = ( + Op.SSTORE(slot_depth + depth, 1) + + Op.MLOAD(chunk_size - 32) + + Op.DELEGATECALL(address=callee) + + Op.POP + ) + addresses.append(pre.deploy_contract(contract)) + + entry_address = addresses[-1] + + tx = Transaction( + gas_limit=gas_limit, + to=entry_address, + sender=pre.fund_eoa(), + ) + + # Calculate expected successful depth based on fork + if fork >= MONAD_NEXT: + # OOM at cumulative memory > 8MB + expected_max_success_depth = Spec.MAX_TX_MEMORY_USAGE // chunk_size + else: + # MONAD_EIGHT: All frames succeed with sufficient gas + expected_max_success_depth = max_depth + + storage = {slot_depth + d: 1 for d in range(expected_max_success_depth)} + + state_test( + pre=pre, + post={entry_address: Account(storage=storage)}, + tx=tx, + ) From b7472ac10c0b912c14d15c03f98926ceea2f85e2 Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Thu, 29 Jan 2026 12:20:07 +0000 Subject: [PATCH 3/7] Update MIP-3 memory cost formula to words // 2 Adapts to MIP-3 spec changes from monad-crypto/MIPs#8: - Memory expansion cost now uses words // MEMORY_WORDS_PER_GAS (=2) - Cost to expand to 8MB reduced from ~786K to ~131K gas - Updated generous_gas() to account for per-word operation costs Co-Authored-By: Claude Opus 4.5 --- .../testing/src/execution_testing/forks/forks/forks.py | 6 ++---- src/ethereum/forks/monad_next/vm/gas.py | 9 +++------ tests/monad_nine/mip3_linear_memory/helpers.py | 8 +++++++- tests/monad_nine/mip3_linear_memory/spec.py | 2 +- tests/monad_nine/mip3_linear_memory/test_oom.py | 4 ++-- 5 files changed, 15 insertions(+), 14 deletions(-) diff --git a/packages/testing/src/execution_testing/forks/forks/forks.py b/packages/testing/src/execution_testing/forks/forks/forks.py index bf369ce41e..2fbe248dd4 100644 --- a/packages/testing/src/execution_testing/forks/forks/forks.py +++ b/packages/testing/src/execution_testing/forks/forks/forks.py @@ -3408,9 +3408,7 @@ def memory_expansion_gas_calculator( Return callable that calculates the gas cost of memory expansion for the fork. """ - gas_costs = cls.gas_costs( - block_number=block_number, timestamp=timestamp - ) + memory_words_per_gas = 2 def fn(*, new_bytes: int, previous_bytes: int = 0) -> int: if new_bytes <= previous_bytes: @@ -3419,7 +3417,7 @@ def fn(*, new_bytes: int, previous_bytes: int = 0) -> int: previous_words = ceiling_division(previous_bytes, 32) def c(w: int) -> int: - return gas_costs.G_MEMORY * w + return w // memory_words_per_gas return c(new_words) - c(previous_words) diff --git a/src/ethereum/forks/monad_next/vm/gas.py b/src/ethereum/forks/monad_next/vm/gas.py index 00d6cabf71..2cf3291716 100644 --- a/src/ethereum/forks/monad_next/vm/gas.py +++ b/src/ethereum/forks/monad_next/vm/gas.py @@ -80,6 +80,7 @@ BLOB_SCHEDULE_MAX = U64(9) MAX_TX_MEMORY_USAGE = 8 * 1024 * 1024 +MEMORY_WORDS_PER_GAS = Uint(2) MIN_BLOB_GASPRICE = Uint(1) BLOB_BASE_FEE_UPDATE_FRACTION = Uint(5007716) @@ -162,12 +163,8 @@ def calculate_memory_gas_cost(size_in_bytes: Uint) -> Uint: """ size_in_words = ceil32(size_in_bytes) // Uint(32) - linear_cost = size_in_words * GAS_MEMORY - total_gas_cost = linear_cost - try: - return total_gas_cost - except ValueError as e: - raise OutOfGasError from e + total_gas_cost = size_in_words // MEMORY_WORDS_PER_GAS + return total_gas_cost def calculate_gas_extend_memory( diff --git a/tests/monad_nine/mip3_linear_memory/helpers.py b/tests/monad_nine/mip3_linear_memory/helpers.py index f7761e1e94..acde3bc6dd 100644 --- a/tests/monad_nine/mip3_linear_memory/helpers.py +++ b/tests/monad_nine/mip3_linear_memory/helpers.py @@ -129,14 +129,20 @@ def generous_gas(fork: Fork) -> int: sstore_cost = gas_costs.G_STORAGE_SET + gas_costs.G_COLD_SLOAD deploy_cost = gas_costs.G_CODE_DEPOSIT_BYTE * len(Op.STOP) access_cost = gas_costs.G_COLD_ACCOUNT_ACCESS - # Assume 5 memory expansions to the max size + # Assume up to 5 memory expansions to the max size linear_memory_expansion_cost = 5 * fork.memory_expansion_gas_calculator()( new_bytes=Spec.MAX_TX_MEMORY_USAGE ) + # Account for per-word operation costs, assume 5 times up to max + max_words = 5 * Spec.MAX_TX_MEMORY_USAGE // 32 + per_word_op_cost = ( + max(gas_costs.G_COPY, gas_costs.G_KECCAK_256_WORD) * max_words + ) return ( constant + sstore_cost + deploy_cost + 5 * access_cost + linear_memory_expansion_cost + + per_word_op_cost ) diff --git a/tests/monad_nine/mip3_linear_memory/spec.py b/tests/monad_nine/mip3_linear_memory/spec.py index 6f0be0291d..5137380629 100644 --- a/tests/monad_nine/mip3_linear_memory/spec.py +++ b/tests/monad_nine/mip3_linear_memory/spec.py @@ -12,7 +12,7 @@ class ReferenceSpec: ref_spec_3 = ReferenceSpec( - "MIPS/MIP-3.md", "fa43faa1bf86ea86a644cd6dfef7c6f2b0b8858e" + "MIPS/MIP-3.md", "a70093b2549d935e31accd62e2e784114253fdc2" ) diff --git a/tests/monad_nine/mip3_linear_memory/test_oom.py b/tests/monad_nine/mip3_linear_memory/test_oom.py index c52b26cf3b..4b27a5ef18 100644 --- a/tests/monad_nine/mip3_linear_memory/test_oom.py +++ b/tests/monad_nine/mip3_linear_memory/test_oom.py @@ -280,8 +280,8 @@ def test_nested_call_oom_insufficient_gas( inner_contract = Op.MLOAD(Spec.MAX_TX_MEMORY_USAGE) inner_address = pre.deploy_contract(inner_contract) - memory_expansion_gas = ( - fork.gas_costs().G_MEMORY * Spec.MAX_TX_MEMORY_USAGE // 32 + memory_expansion_gas = fork.memory_expansion_gas_calculator()( + new_bytes=Spec.MAX_TX_MEMORY_USAGE ) insufficient_gas = memory_expansion_gas // 2 gas_limit = generous_gas(fork) From 3e2baf85ea1d025eea850a3fb77f57faf5d5c535 Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Mon, 2 Feb 2026 13:31:54 +0000 Subject: [PATCH 4/7] Fix, tidy and add test for MIP-3 Co-Authored-By: Claude Opus 4.5 --- tests/frontier/opcodes/test_data_copy_oog.py | 51 +- .../monad_nine/mip3_linear_memory/helpers.py | 26 +- .../mip3_linear_memory/test_gas_cost.py | 270 ++++- .../monad_nine/mip3_linear_memory/test_oom.py | 1069 +++++++++++++---- 4 files changed, 1109 insertions(+), 307 deletions(-) diff --git a/tests/frontier/opcodes/test_data_copy_oog.py b/tests/frontier/opcodes/test_data_copy_oog.py index 36ab996d1d..f71c5bb50e 100644 --- a/tests/frontier/opcodes/test_data_copy_oog.py +++ b/tests/frontier/opcodes/test_data_copy_oog.py @@ -12,6 +12,8 @@ Storage, Transaction, ) +from execution_testing.forks.forks.forks import MONAD_NEXT +from execution_testing.forks.helpers import Fork REFERENCE_SPEC_GIT_PATH = "EIPS/eip-211.md" REFERENCE_SPEC_VERSION = "1.0.0" @@ -36,27 +38,17 @@ @pytest.mark.parametrize( - "subcall_gas,expect_success", + "expect_success", [ - pytest.param( - 5000, - True, - id="sufficient_gas", - ), - pytest.param( - # Enough for: MSTORE + memory expansion + static CALLDATACOPY - # But NOT enough for word copy cost (3 gas per 32-byte word) - 150, - False, - id="insufficient_gas_for_word_copy_cost", - ), + pytest.param(True, id="sufficient_gas"), + pytest.param(False, id="insufficient_gas_for_word_copy_cost"), ], ) def test_calldatacopy_word_copy_oog( state_test: StateTestFiller, pre: Alloc, - subcall_gas: int, expect_success: bool, + fork: Fork, ) -> None: """ Test that CALLDATACOPY properly consumes gas for word copy cost. @@ -64,6 +56,16 @@ def test_calldatacopy_word_copy_oog( Uses a sub-call with controlled gas to isolate the test from intrinsic gas costs that vary across forks. """ + if expect_success: + subcall_gas = 5000 + else: + # MIP-3 (MONAD_NEXT) uses linear memory cost (words // 2) instead of + # quadratic (3*words + words²/512), so memory expansion is cheaper. + # Pre-MIP-3: MSTORE costs 3 + 98 = 101 gas for 32 words + # MIP-3: MSTORE costs 3 + 16 = 19 gas for 32 words + # We need enough gas for setup but not for CALLDATACOPY word copy cost. + subcall_gas = 68 if fork >= MONAD_NEXT else 150 + storage = Storage() storage_key = storage.store_next(1 if expect_success else 0) @@ -121,29 +123,26 @@ def test_calldatacopy_word_copy_oog( @pytest.mark.parametrize( - "subcall_gas,expect_success", + "expect_success", [ - pytest.param( - 5000, - True, - id="sufficient_gas", - ), - pytest.param( - 150, - False, - id="insufficient_gas_for_word_copy_cost", - ), + pytest.param(True, id="sufficient_gas"), + pytest.param(False, id="insufficient_gas_for_word_copy_cost"), ], ) def test_codecopy_word_copy_oog( state_test: StateTestFiller, pre: Alloc, - subcall_gas: int, expect_success: bool, + fork: Fork, ) -> None: """ Test that CODECOPY properly consumes gas for word copy cost. """ + if expect_success: + subcall_gas = 5000 + else: + subcall_gas = 68 if fork >= MONAD_NEXT else 150 + storage = Storage() storage_key = storage.store_next(1 if expect_success else 0) diff --git a/tests/monad_nine/mip3_linear_memory/helpers.py b/tests/monad_nine/mip3_linear_memory/helpers.py index acde3bc6dd..8303fd834c 100644 --- a/tests/monad_nine/mip3_linear_memory/helpers.py +++ b/tests/monad_nine/mip3_linear_memory/helpers.py @@ -11,6 +11,8 @@ from .spec import Spec +COLD_ACCESS_TARGET_ADDRESS = 0x1234567890ABCDEF1234567890ABCDEF12345678 + def prepare_stack_memory_opcode(opcode: Opcode, size: int) -> Bytecode: """Prepare valid stack for memory-allocating opcode.""" @@ -22,15 +24,15 @@ def prepare_stack_memory_opcode(opcode: Opcode, size: int) -> Bytecode: return Op.PUSH32(size) + Op.PUSH0 + Op.PUSH0 elif opcode == Op.EXTCODECOPY: # stack: address, destOffset, offset, size - address = 0x1234567890ABCDEF1234567890ABCDEF12345678 - return Op.PUSH32(size) + Op.PUSH0 + Op.PUSH0 + Op.PUSH20(address) + return ( + Op.PUSH32(size) + + Op.PUSH0 + + Op.PUSH0 + + Op.PUSH20(COLD_ACCESS_TARGET_ADDRESS) + ) elif opcode == Op.MCOPY: # stack: srcOffset, destOffset, size return Op.PUSH32(size) + Op.PUSH0 + Op.PUSH0 - # FIXME: this goes out of bounds, no way to setup return buffer easily - # elif opcode == Op.RETURNDATACOPY: - # # stack: destOffset, offset, size - # return Op.PUSH32(size) + Op.PUSH0 + Op.PUSH0 elif opcode == Op.SHA3: # stack: offset, size return Op.PUSH32(size) + Op.PUSH0 @@ -72,48 +74,44 @@ def prepare_stack_memory_opcode(opcode: Opcode, size: int) -> Bytecode: return Op.PUSH32(offset) elif opcode == Op.CALL: # stack: gas, address, value, argsOffset, argsSize, retOffset, retSize - address = 0x1234567890ABCDEF1234567890ABCDEF12345678 return ( Op.PUSH32(size) + Op.PUSH0 # retSize, retOffset + Op.PUSH0 + Op.PUSH0 # argsSize, argsOffset + Op.PUSH0 # value - + Op.PUSH20(address) + + Op.PUSH20(COLD_ACCESS_TARGET_ADDRESS) + Op.GAS # use all available gas ) elif opcode == Op.CALLCODE: # stack: gas, address, value, argsOffset, argsSize, retOffset, retSize - address = 0x1234567890ABCDEF1234567890ABCDEF12345678 return ( Op.PUSH32(size) + Op.PUSH0 # retSize, retOffset + Op.PUSH0 + Op.PUSH0 # argsSize, argsOffset + Op.PUSH0 # value - + Op.PUSH20(address) + + Op.PUSH20(COLD_ACCESS_TARGET_ADDRESS) + Op.GAS # use all available gas ) elif opcode == Op.DELEGATECALL: # stack: gas, address, argsOffset, argsSize, retOffset, retSize - address = 0x1234567890ABCDEF1234567890ABCDEF12345678 return ( Op.PUSH32(size) + Op.PUSH0 # retSize, retOffset + Op.PUSH0 + Op.PUSH0 # argsSize, argsOffset - + Op.PUSH20(address) + + Op.PUSH20(COLD_ACCESS_TARGET_ADDRESS) + Op.GAS # use all available gas ) elif opcode == Op.STATICCALL: # stack: gas, address, argsOffset, argsSize, retOffset, retSize - address = 0x1234567890ABCDEF1234567890ABCDEF12345678 return ( Op.PUSH32(size) + Op.PUSH0 # retSize, retOffset + Op.PUSH0 + Op.PUSH0 # argsSize, argsOffset - + Op.PUSH20(address) + + Op.PUSH20(COLD_ACCESS_TARGET_ADDRESS) + Op.GAS # use all available gas ) else: diff --git a/tests/monad_nine/mip3_linear_memory/test_gas_cost.py b/tests/monad_nine/mip3_linear_memory/test_gas_cost.py index db1e3d62f9..f52fd5bfc9 100644 --- a/tests/monad_nine/mip3_linear_memory/test_gas_cost.py +++ b/tests/monad_nine/mip3_linear_memory/test_gas_cost.py @@ -8,6 +8,7 @@ from execution_testing import ( Account, Alloc, + Bytecode, Op, ParameterSet, StateTestFiller, @@ -18,17 +19,17 @@ from execution_testing.forks.helpers import Fork from execution_testing.vm import Opcode -from .helpers import prepare_stack_memory_opcode +from .helpers import COLD_ACCESS_TARGET_ADDRESS, prepare_stack_memory_opcode from .spec import Spec, ref_spec_3 REFERENCE_SPEC_GIT_PATH = ref_spec_3.git_path REFERENCE_SPEC_VERSION = ref_spec_3.version slot_code_worked = 0x1 +slot_gas_used = 0x2 value_code_worked = 0x1234 pytestmark = [ - pytest.mark.valid_from("MONAD_EIGHT"), pytest.mark.pre_alloc_group( "mip3_tests", reason="Tests linear memory MIP-3", @@ -36,6 +37,7 @@ ] +@pytest.mark.valid_from("MONAD_EIGHT") @pytest.mark.parametrize("fail", [True, False]) def test_cost_non_quadratic( state_test: StateTestFiller, @@ -77,7 +79,6 @@ def memory_copy_opcodes( Memory-reading opcodes which allocate memory. Includes copy, hashing, and logging opcodes. """ - valid_opcodes = set(fork.valid_opcodes()) gas_costs = fork.gas_costs() memory_opcodes = { @@ -99,25 +100,14 @@ def memory_copy_opcodes( Op.DELEGATECALL: gas_costs.G_WARM_ACCOUNT_ACCESS, Op.STATICCALL: gas_costs.G_WARM_ACCOUNT_ACCESS, Op.CALLCODE: gas_costs.G_WARM_ACCOUNT_ACCESS, - # FIXME: this goes out of bounds, no way to setup return buffer easily - # Op.RETURNDATACOPY: gas_costs.G_VERY_LOW, + # RETURNDATACOPY tested separately in test_returndatacopy_gas_cost } - cold_access_opcodes = ( - Op.EXTCODECOPY, - Op.CALL, - Op.DELEGATECALL, - Op.STATICCALL, - Op.CALLCODE, - ) - - for opcode, base_gas in memory_opcodes.items(): - if opcode not in valid_opcodes: - continue - cold_gas = base_gas - if opcode in cold_access_opcodes: - cold_gas = gas_costs.G_COLD_ACCOUNT_ACCESS - yield opcode, base_gas, cold_gas + for opcode, warm_gas in memory_opcodes.items(): + # Cold/warm gas testing is outside of the scope of the test + # Each test should warm accessed accounts in prelude_code + cold_gas = warm_gas + yield opcode, warm_gas, cold_gas def memory_stack_opcodes( @@ -126,7 +116,6 @@ def memory_stack_opcodes( """ Stack-memory opcodes which always read at least 1 byte or 1 word. """ - valid_opcodes = set(fork.valid_opcodes()) gas_costs = fork.gas_costs() memory_opcodes = { @@ -135,11 +124,9 @@ def memory_stack_opcodes( Op.MSTORE8: gas_costs.G_VERY_LOW, } - for opcode, base_gas in memory_opcodes.items(): - if opcode not in valid_opcodes: - continue - cold_gas = base_gas - yield pytest.param(opcode, base_gas, cold_gas, id=f"{opcode}") + for opcode, warm_gas in memory_opcodes.items(): + cold_gas = warm_gas + yield pytest.param(opcode, warm_gas, cold_gas, id=f"{opcode}") def memory_sizes( @@ -155,6 +142,7 @@ def memory_sizes( yield pytest.param(0x2000, id="above_quadratic_threshold_copy") if fork >= MONAD_NEXT: yield pytest.param(Spec.MAX_TX_MEMORY_USAGE, id="max") + yield pytest.param(Spec.MAX_TX_MEMORY_USAGE + 32, id="exceed") def memory_copy_opcodes_with_size( @@ -179,7 +167,10 @@ def memory_copy_opcodes_with_size( for opcode, warm_gas, cold_gas in memory_copy_opcodes(fork): for size_param in memory_sizes(fork): - if opcode in exclude_max_opcodes and size_param.id == "max": + if opcode in exclude_max_opcodes and size_param.id in [ + "max", + "exceed", + ]: continue yield pytest.param( opcode, @@ -190,6 +181,7 @@ def memory_copy_opcodes_with_size( ) +@pytest.mark.valid_from("MONAD_EIGHT") @pytest.mark.parametrize_by_fork( "opcode,warm_gas,cold_gas,size", memory_copy_opcodes_with_size ) @@ -234,7 +226,6 @@ def test_memory_copy_opcodes( Op.CODECOPY, Op.EXTCODECOPY, Op.MCOPY, - Op.RETURNDATACOPY, ): dynamic_gas_cost = fork.gas_costs().G_COPY * ((size + 31) // 32) if opcode == Op.SHA3: @@ -265,16 +256,21 @@ def test_memory_copy_opcodes( fork=fork, state_test=state_test, pre=pre, + prelude_code=Op.BALANCE(COLD_ACCESS_TARGET_ADDRESS), setup_code=setup_code, subject_code=opcode, tear_down_code=Op.STOP, cold_gas=cold_gas + dynamic_gas_cost + memory_expansion_cost, warm_gas=warm_gas + dynamic_gas_cost + memory_expansion_cost, - # OOG testing depends on CALL status code, doesn't work with REVERT. - out_of_gas_testing=False if opcode == Op.REVERT else True, + # OOG testing depends on CALL status code, doesn't work with REVERT + # or OOM. + out_of_gas_testing=False + if opcode == Op.REVERT or size > Spec.MAX_TX_MEMORY_USAGE + else True, ) +@pytest.mark.valid_from("MONAD_EIGHT") @pytest.mark.parametrize_by_fork( "opcode,warm_gas,cold_gas", memory_stack_opcodes ) @@ -322,4 +318,218 @@ def test_memory_stack_opcodes( tear_down_code=Op.STOP, cold_gas=cold_gas + memory_expansion_cost, warm_gas=warm_gas + memory_expansion_cost, + out_of_gas_testing=False if size > Spec.MAX_TX_MEMORY_USAGE else True, + ) + + +@pytest.mark.valid_from("MONAD_EIGHT") +@pytest.mark.parametrize_by_fork("size", memory_sizes) +@pytest.mark.parametrize( + "initial_memory", + [bytes(range(0x00, 0x100)), bytes()], + ids=["from_existent_memory", "from_empty_memory"], +) +def test_returndatacopy_gas_cost( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + size: int, + initial_memory: bytes, +) -> None: + """ + Test that RETURNDATACOPY consumes correct gas under MIP-3. + + RETURNDATACOPY requires a return data buffer to exist, so we first call + a contract that returns the required amount of data in the setup_code. + """ + gas_costs = fork.gas_costs() + cost_memory_bytes = fork.memory_expansion_gas_calculator() + + memory_expansion_cost = cost_memory_bytes( + new_bytes=size, + previous_bytes=len(initial_memory), + ) + dynamic_gas_cost = gas_costs.G_COPY * ((size + 31) // 32) + base_gas = gas_costs.G_VERY_LOW + + returner_address = pre.deploy_contract(Op.RETURN(0, size)) + + setup_code = ( + Op.CALL(address=returner_address) + + Op.CALLDATACOPY(0x00, 0x00, len(initial_memory)) + + Op.PUSH32(size) + + Op.PUSH0 + + Op.PUSH0 + ) + + gas_test( + fork=fork, + state_test=state_test, + pre=pre, + # Warm the address to CALL to have stable gas cost. + prelude_code=Op.BALANCE(returner_address), + setup_code=setup_code, + subject_code=Op.RETURNDATACOPY, + tear_down_code=Op.STOP, + cold_gas=base_gas + dynamic_gas_cost + memory_expansion_cost, + warm_gas=base_gas + dynamic_gas_cost + memory_expansion_cost, + out_of_gas_testing=False if size > Spec.MAX_TX_MEMORY_USAGE else True, + ) + + +@pytest.mark.parametrize( + "offsets,expected_memory_cost", + [ + pytest.param([0], 0, id="offset_0_first_word_free"), + pytest.param([15], 0, id="offset_15_first_word_free"), + pytest.param([31], 0, id="offset_31_first_word_free"), + pytest.param([32], 1, id="offset_32_second_word_costs"), + pytest.param([33], 1, id="offset_33_second_word_costs"), + pytest.param([63], 1, id="offset_63_second_word_costs"), + pytest.param( + [63, 95, 127], + 2, + id="delta_even_odd_mstore", + ), + pytest.param( + [63, 127], + 2, + id="even_word_expansion", + ), + pytest.param( + [31, 95], + 1, + id="odd_word_expansion", + ), + pytest.param( + [31, 63, 95, 127, 159], + 2, + id="multiple_expansions_1word_5times", + ), + pytest.param( + [159], + 2, + id="single_expansion_5words", + ), + ], +) +@pytest.mark.valid_from("MONAD_NEXT") +def test_consecutive_expansions( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + offsets: list[int], + expected_memory_cost: int, +) -> None: + """ + Test consecutive memory expansions under MIP-3's `words // 2` formula. + + Verifies that: + - First memory word is free (words // 2 = 0 for 1 word) + - Delta charges work correctly for successive expansions + - Even word expansions: 2 words cost 1, 4 words cost 2 + - Odd word expansions: 1 word cost 0, 3 words cost 1 + - Multiple small expansions equal one large expansion + """ + gas_costs = fork.gas_costs() + base_gas_per_op = gas_costs.G_VERY_LOW + + setup_code = Bytecode() + for offset in reversed(offsets): + setup_code += Op.PUSH1(0xFF) + Op.PUSH1(offset) + + subject_code = Bytecode() + for _ in offsets: + subject_code += Op.MSTORE8 + + total_gas = len(offsets) * base_gas_per_op + expected_memory_cost + + gas_test( + fork=fork, + state_test=state_test, + pre=pre, + setup_code=setup_code, + subject_code=subject_code, + tear_down_code=Op.STOP, + cold_gas=total_gas, + warm_gas=total_gas, + ) + + +@pytest.mark.valid_from("MONAD_NEXT") +@pytest.mark.parametrize( + "opcode", + [Op.EXTCODECOPY, Op.CALL, Op.DELEGATECALL, Op.STATICCALL, Op.CALLCODE], +) +@pytest.mark.parametrize_by_fork( + "size", + lambda fork: ( + p + for p in memory_sizes(fork) + if p.values[0] >= Spec.MAX_TX_MEMORY_USAGE + ), +) +@pytest.mark.parametrize( + "initial_memory", + [bytes(range(0x00, 0x100)), bytes()], + ids=["from_existent_memory", "from_empty_memory"], +) +def test_oom_account_stays_cold( + state_test: StateTestFiller, + pre: Alloc, + opcode: Opcode, + fork: Fork, + size: int, + initial_memory: bytes, +) -> None: + """ + Test that OOM reverts account warming for cold access opcodes. + + For "max" size (no OOM): account warms, warm_gas < cold_gas + For "exceed" size (OOM): account stays cold, warm_gas = cold_gas + """ + gas_costs = fork.gas_costs() + cost_memory_bytes = fork.memory_expansion_gas_calculator() + + memory_expansion_cost = cost_memory_bytes( + new_bytes=size, + previous_bytes=len(initial_memory), + ) + + if opcode == Op.EXTCODECOPY: + dynamic_gas_cost = gas_costs.G_COPY * ((size + 31) // 32) + else: + dynamic_gas_cost = 0 + + cold_gas = ( + gas_costs.G_COLD_ACCOUNT_ACCESS + + dynamic_gas_cost + + memory_expansion_cost + ) + + # For OOM (exceed), account stays cold so warm_gas = cold_gas + # For no OOM (max), account warms so warm_gas uses warm access cost + if size > Spec.MAX_TX_MEMORY_USAGE: + warm_gas = cold_gas + else: + warm_gas = ( + gas_costs.G_WARM_ACCOUNT_ACCESS + + dynamic_gas_cost + + memory_expansion_cost + ) + + setup_code = Op.CALLDATACOPY( + 0x00, 0x00, len(initial_memory) + ) + prepare_stack_memory_opcode(opcode, size) + + gas_test( + fork=fork, + state_test=state_test, + pre=pre, + setup_code=setup_code, + subject_code=opcode, + tear_down_code=Op.STOP, + cold_gas=cold_gas, + warm_gas=warm_gas, + out_of_gas_testing=False, ) diff --git a/tests/monad_nine/mip3_linear_memory/test_oom.py b/tests/monad_nine/mip3_linear_memory/test_oom.py index 4b27a5ef18..5afc22ba35 100644 --- a/tests/monad_nine/mip3_linear_memory/test_oom.py +++ b/tests/monad_nine/mip3_linear_memory/test_oom.py @@ -28,6 +28,7 @@ slot_call_result = next(_slot) slot_inner_worked = next(_slot) slot_all_gas_consumed = next(_slot) +slot_inner_gas_consumed = next(_slot) slot_returndata_size_before = next(_slot) slot_returndata_size_after = next(_slot) slot_outer_memory_preserved = next(_slot) @@ -214,6 +215,7 @@ def test_nested_call_oom( [ pytest.param(Op.STOP, 1, False, id="success"), pytest.param(Op.MLOAD(Spec.MAX_TX_MEMORY_USAGE), 0, False, id="oom"), + pytest.param(Op.MLOAD(2**256 - 1), 0, True, id="oog_before_oom"), pytest.param(Op.REVERT(0, 0), 0, False, id="revert"), pytest.param(Op.INVALID, 0, True, id="invalid"), ], @@ -228,7 +230,7 @@ def test_nested_call_gas_consumption( ) -> None: """ Test gas consumption behavior of CALL with different callee outcomes. - REVERT should not consume all gas, while INVALID does. + OOM should not consume all gas, unlike OOG and INVALID. """ inner_address = pre.deploy_contract(callee_code) @@ -267,58 +269,6 @@ def test_nested_call_gas_consumption( ) -def test_nested_call_oom_insufficient_gas( - state_test: StateTestFiller, - pre: Alloc, - fork: Fork, -) -> None: - """ - Test OOM behavior when CALL is given insufficient gas for memory expansion. - - This tests which check comes first: gas availability or memory limit. - """ - inner_contract = Op.MLOAD(Spec.MAX_TX_MEMORY_USAGE) - inner_address = pre.deploy_contract(inner_contract) - - memory_expansion_gas = fork.memory_expansion_gas_calculator()( - new_bytes=Spec.MAX_TX_MEMORY_USAGE - ) - insufficient_gas = memory_expansion_gas // 2 - gas_limit = generous_gas(fork) - - outer_contract = ( - Op.SSTORE(slot_code_worked, value_code_worked) - + Op.SSTORE( - slot_call_result, - Op.CALL(address=inner_address, gas=insufficient_gas), - ) - + Op.SSTORE( - slot_all_gas_consumed, Op.LT(Op.GAS, gas_limit - insufficient_gas) - ) - ) - outer_address = pre.deploy_contract(outer_contract) - - tx = Transaction( - gas_limit=gas_limit, - to=outer_address, - sender=pre.fund_eoa(), - ) - - state_test( - pre=pre, - post={ - outer_address: Account( - storage={ - slot_code_worked: value_code_worked, - slot_call_result: 0, - slot_all_gas_consumed: 1, - } - ) - }, - tx=tx, - ) - - @pytest.mark.parametrize("exceed", [True, False]) @pytest.mark.with_all_create_opcodes def test_nested_create_oom( @@ -452,6 +402,8 @@ def test_all_memory_opcodes_oom( ) -> None: """ Test OOM behavior for all memory-allocating opcodes. + + RETURNDATACOPY tested separately. """ # LOG opcodes have high per-byte cost, CREATE opcodes have initcode size # limits @@ -493,6 +445,51 @@ def test_all_memory_opcodes_oom( ) +@pytest.mark.parametrize("exceed", [True, False]) +def test_returndatacopy_oom( + state_test: StateTestFiller, + pre: Alloc, + exceed: bool, + fork: Fork, +) -> None: + """ + Test OOM behavior for RETURNDATACOPY. + + For exceed=False: Call a contract that returns data of the target size, + then use RETURNDATACOPY to copy it. This validates no OOM occurs. + + For exceed=True: We cannot create return data of that size because the + RETURN opcode would OOM. Instead, skip the CALL and call RETURNDATACOPY + directly. RETURNDATACOPY will fail with an out-of-buffer read error + (not OOM) because there is no return data to copy from. + """ + size = Spec.MAX_TX_MEMORY_USAGE + (1 if exceed else 0) + + contract = Op.SSTORE(slot_code_worked, value_code_worked) + + if not exceed: + returner_address = pre.deploy_contract(Op.RETURN(0, size)) + + contract += Op.CALL(address=returner_address) + contract += Op.RETURNDATACOPY(0, 0, size) + + contract_address = pre.deploy_contract(contract) + + tx = Transaction( + gas_limit=generous_gas(fork), + to=contract_address, + sender=pre.fund_eoa(), + ) + + storage = {} if exceed else {slot_code_worked: value_code_worked} + + state_test( + pre=pre, + post={contract_address: Account(storage=storage)}, + tx=tx, + ) + + @pytest.mark.parametrize("exceeds_at_depth", [1, 2, 3, 15, 128]) def test_nested_frames_oom( state_test: StateTestFiller, @@ -548,6 +545,114 @@ def test_nested_frames_oom( ) +@pytest.mark.parametrize( + "chunk_size,expected_successful_calls", + [ + (Spec.MAX_TX_MEMORY_USAGE // 8, 8), + (Spec.MAX_TX_MEMORY_USAGE // 4, 4), + (Spec.MAX_TX_MEMORY_USAGE // 2, 2), + ], +) +def test_recursive_frames_oom( + state_test: StateTestFiller, + pre: Alloc, + chunk_size: int, + expected_successful_calls: int, + fork: Fork, +) -> None: + """ + Test recursive calls until cumulative memory exceeds limit. + """ + slot_depth_base = 0x100 + slot_counter = next(_slot) + + code_increment_counter = ( + Op.TLOAD(slot_counter) + + Op.DUP1 + + Op.TSTORE(slot_counter, Op.PUSH1(1) + Op.ADD) + ) + contract = ( + Op.SSTORE(Op.ADD(slot_depth_base, code_increment_counter), 1) + + Op.MLOAD(chunk_size - 32) + + Op.DELEGATECALL(address=Op.ADDRESS) + ) + contract_address = pre.deploy_contract(contract) + + tx = Transaction( + gas_limit=generous_gas(fork), + to=contract_address, + sender=pre.fund_eoa(), + ) + + storage = { + slot_depth_base + d: 1 for d in range(expected_successful_calls) + } + + state_test( + pre=pre, + post={contract_address: Account(storage=storage)}, + tx=tx, + ) + + +def test_inner_frame_incremental_memory_allocation( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Test repeated calls to inner contract with increasing memory allocation. + + Tests memory recovery after OOM and successful inner call releases memory. + + The outer frame allocates 32 bytes for calldata (MSTORE). The inner + contract computes: offset = target - 64, then MLOAD(offset) which + allocates (offset + 32) bytes. Total = 32 + (offset + 32) = target. + """ + inner_contract = Op.MLOAD(Op.CALLDATALOAD(0)) + inner_address = pre.deploy_contract(inner_contract) + + sizes = [ + Spec.MAX_TX_MEMORY_USAGE, + Spec.MAX_TX_MEMORY_USAGE // 4, + Spec.MAX_TX_MEMORY_USAGE // 2, + Spec.MAX_TX_MEMORY_USAGE // 2, + Spec.MAX_TX_MEMORY_USAGE - 32, + Spec.MAX_TX_MEMORY_USAGE, + Spec.MAX_TX_MEMORY_USAGE + 32, + 2 * Spec.MAX_TX_MEMORY_USAGE, + ] + + outer = Op.SSTORE(slot_code_worked, value_code_worked) + for size in sizes: + outer = ( + # pre-allocate for MSIZE to work + Op.MSTORE(0, 0) + # store size to allocate in the inner frame, minus + # size of MLOAD allocation, minus current allocation + + Op.MSTORE(0, Op.SUB(size - 32, Op.MSIZE)) + + Op.SSTORE(size, Op.CALL(address=inner_address, args_size=32)) + ) + outer + + outer_address = pre.deploy_contract(outer) + + tx = Transaction( + gas_limit=generous_gas(fork), + to=outer_address, + sender=pre.fund_eoa(), + ) + + storage = {slot_code_worked: value_code_worked} + for size in sizes: + storage[size] = 1 if size <= Spec.MAX_TX_MEMORY_USAGE else 0 + + state_test( + pre=pre, + post={outer_address: Account(storage=storage)}, + tx=tx, + ) + + @pytest.mark.parametrize( "inner_exit", [ @@ -785,6 +890,60 @@ def test_oom_clears_returndata_create( ) +@pytest.mark.parametrize("exceed", [True, False]) +@pytest.mark.with_all_create_opcodes +def test_create_return_oom( + state_test: StateTestFiller, + pre: Alloc, + exceed: bool, + create_opcode: Op, + fork: Fork, +) -> None: + """ + Test OOM in CREATE's RETURN phase (not in initcode execution). + + Factory allocates some memory, then initcode tries to RETURN large data. + The RETURN itself causes OOM, not the initcode execution. + """ + factory_alloc = Spec.MAX_TX_MEMORY_USAGE + + return_size = 32 if exceed else 0 + + initcode = Op.RETURN(0, return_size) + initcode_bytes = bytes(initcode) + b"\x00" * (32 - (len(initcode) % 32)) + + factory = ( + Op.MLOAD(factory_alloc - 32) + + Op.MSTORE(0, Op.PUSH32(initcode_bytes)) + + Op.SSTORE(slot_call_result, create_opcode(size=len(initcode))) + + Op.SSTORE(slot_code_worked, value_code_worked) + ) + factory_address = pre.deploy_contract(factory) + new_contract_address = compute_create_address( + address=factory_address, + nonce=1, + initcode=initcode, + opcode=create_opcode, + ) + + tx = Transaction( + gas_limit=generous_gas(fork), + to=factory_address, + sender=pre.fund_eoa(), + ) + + storage = { + slot_code_worked: value_code_worked, + slot_call_result: 0 if exceed else new_contract_address, + } + + state_test( + pre=pre, + post={factory_address: Account(storage=storage)}, + tx=tx, + ) + + @pytest.mark.parametrize( "outer_alloc_size,inner_alloc_size", [ @@ -949,223 +1108,379 @@ def test_memory_word_rounding_at_limit( @pytest.mark.parametrize("exceed", [True, False]) +@pytest.mark.parametrize("trigger_oog", [True, False]) @pytest.mark.parametrize( - "scenario", - [ - pytest.param("static_violation"), - pytest.param("oog_value_transfer"), - pytest.param("oog_access_cost"), - pytest.param("insufficient_balance"), - ], + "gas_cost_type", + ["account_create", "value_transfer", "access_cost", "memory_expansion"], ) -def test_oom_check_ordering_in_call( +def test_charge_gas_before_oom_check( state_test: StateTestFiller, pre: Alloc, - scenario: str, + gas_cost_type: str, exceed: bool, + trigger_oog: bool, fork: Fork, ) -> None: """ - Test OOM check placement relative to other CALL checks. + Test that charge_gas happens BEFORE OOM check in CALL opcode. - CALL check order in system.py: - 1. charge_gas (memory extension + access cost + value transfer cost) - 2. OOM check (update_memory_high_watermark) - 3. Static call violation check (WriteInStaticContext) - 4. Balance check (returns 0, refunds gas) + Sets up an inner CALL that will OOG if trigger_oog=True (due to various + gas costs), with a return buffer that causes OOM if exceed=True. - Each scenario triggers a specific check to verify ordering relative to OOM. - The exceed parameter controls whether the memory access would cause OOM. - - NOTE: for checking the order of checks wrt. memory expansion _gas cost_ - refert to test_oom_deep.py, as it includes testing against previous fork. + If charge_gas runs before OOM check, OOG happens regardless of exceed. """ gas_limit = generous_gas(fork) gas_costs = fork.gas_costs() - offset = ( - Spec.MAX_TX_MEMORY_USAGE if exceed else Spec.MAX_TX_MEMORY_USAGE - 32 - ) - - if scenario == "static_violation": - # Test: static check happens AFTER OOM check in CALL opcode. - # Setup: Inner does CALL with value (static violation) with return - # buffer that may or may not OOM. - gas_threshold = gas_limit // 64 - - inner_contract = Op.CALL( - address=pre.empty_account(), - value=1, - ret_offset=0, - ret_size=32, + if gas_cost_type == "account_create": + target = pre.fund_eoa(amount=0 if trigger_oog else 1) + call_value = 1 + inner_offset = 32 + inner_gas = ( + 7 * gas_costs.G_VERY_LOW + + gas_costs.G_WARM_ACCOUNT_ACCESS + + gas_costs.G_CALL_VALUE + + gas_costs.G_NEW_ACCOUNT + + fork.memory_expansion_gas_calculator()(new_bytes=inner_offset) + - 1 ) - inner_address = pre.deploy_contract(inner_contract, balance=10**18) - - outer_contract = ( - # Allocate almost the entire memory, so next allocation of 32 bytes - # OOMs if exceed is True. - Op.MLOAD(offset - 32) - + Op.SSTORE(slot_code_worked, value_code_worked) - + Op.SSTORE(slot_call_result, 123) - + Op.SSTORE(slot_all_gas_consumed, 123) - + Op.SSTORE(slot_call_result, Op.STATICCALL(address=inner_address)) - + Op.SSTORE(slot_all_gas_consumed, Op.LT(Op.GAS, gas_threshold)) + warm_target = True + elif gas_cost_type == "value_transfer": + target = pre.fund_eoa(amount=1) + call_value = 1 if trigger_oog else 0 + inner_offset = 32 + inner_gas = ( + 7 * gas_costs.G_VERY_LOW + + gas_costs.G_WARM_ACCOUNT_ACCESS + + gas_costs.G_CALL_VALUE + + fork.memory_expansion_gas_calculator()(new_bytes=inner_offset) + - 1 ) - outer_address = pre.deploy_contract(outer_contract) - - post = { - outer_address: Account( - storage={ - slot_code_worked: value_code_worked, - slot_call_result: 0, - # OOM check runs first: - slot_all_gas_consumed: 0 if exceed else 1, - } - ) - } - - elif scenario == "oog_value_transfer": - # Test: charge_gas (for value transfer cost) happens BEFORE OOM check. - warm_account = pre.empty_account() + warm_target = True + elif gas_cost_type == "access_cost": + target = pre.empty_account() + call_value = 0 + inner_offset = 32 inner_gas = ( - gas_costs.G_WARM_ACCOUNT_ACCESS + gas_costs.G_CALL_VALUE - 10 + 7 * gas_costs.G_VERY_LOW + + gas_costs.G_COLD_ACCOUNT_ACCESS + + fork.memory_expansion_gas_calculator()(new_bytes=inner_offset) + - 1 ) - gas_threshold = gas_limit - inner_gas - - inner_contract = Op.MSTORE( - 0, - Op.CALL( - gas=0, - address=warm_account, - value=1, - ret_offset=0, - ret_size=32, - ), - ) + Op.RETURN(0, 32) - inner_address = pre.deploy_contract(inner_contract) - - outer_contract = ( - # Allocate almost the entire memory, so next allocation of 32 bytes - # OOMs if exceed is True. - Op.MLOAD(offset - 32) - + Op.BALANCE(warm_account) - + Op.SSTORE(slot_code_worked, value_code_worked) - + Op.SSTORE(slot_call_result, 123) - + Op.SSTORE(slot_all_gas_consumed, 123) - + Op.DELEGATECALL( - gas=inner_gas, - address=inner_address, - ) - + Op.SSTORE(slot_all_gas_consumed, Op.LT(Op.GAS, gas_threshold)) - + Op.RETURNDATACOPY(0, 0, Op.RETURNDATASIZE) - + Op.SSTORE(slot_call_result, Op.MLOAD(0)) + warm_target = not trigger_oog + elif gas_cost_type == "memory_expansion": + target = pre.fund_eoa(amount=1) + call_value = 0 + inner_offset = 1024 * 1024 if trigger_oog else 32 + inner_gas = ( + 7 + + gas_costs.G_VERY_LOW + + gas_costs.G_WARM_ACCOUNT_ACCESS + + fork.memory_expansion_gas_calculator()(new_bytes=1024 * 1024) + - 1 ) - outer_address = pre.deploy_contract(outer_contract, balance=10**18) + warm_target = True + else: + raise Exception(f"Unknown scenario: {gas_cost_type}") - post = { - outer_address: Account( - storage={ - slot_code_worked: value_code_worked, - slot_call_result: 0, - # oog check comes first so regardless of OOM: - slot_all_gas_consumed: 1, - } - ) - } - - elif scenario == "oog_access_cost": - # Test: charge_gas (for cold access cost) happens BEFORE OOM check. - # Setup: CALL to cold address with contract with memory access. - # Result: With exceed=True, callee OOMs. With exceed=False, succeeds. - cold_account = pre.empty_account() - inner_gas = gas_costs.G_COLD_ACCOUNT_ACCESS - 10 - gas_threshold = gas_limit - inner_gas - - inner_contract = Op.MSTORE( - 0, - Op.CALL( - gas=0, - address=cold_account, - ret_offset=0, - ret_size=32, + # ret_size=inner_offset is allocating extra to cause OOM if exceed. + inner_contract = Op.CALL( + gas=0, address=target, value=call_value, ret_size=inner_offset + ) + inner_address = pre.deploy_contract(inner_contract) + + offset = ( + Spec.MAX_TX_MEMORY_USAGE + if exceed + else Spec.MAX_TX_MEMORY_USAGE - inner_offset + ) + mem_gas, mem_result = 0, 32 + + outer_contract = Op.MLOAD(offset - inner_offset) + Op.POP( + Op.BALANCE(inner_address) + ) + if warm_target: + outer_contract += Op.POP(Op.BALANCE(target)) + + outer_contract += ( + # use MSTORE to avoid expensive SSTOREs in the test + Op.MSTORE(mem_gas, Op.GAS) + + Op.MSTORE( + mem_result, Op.DELEGATECALL(gas=inner_gas, address=inner_address) + ) + + Op.SSTORE( + slot_inner_gas_consumed, + Op.LT( + inner_gas + + gas_costs.G_WARM_ACCOUNT_ACCESS + + gas_costs.G_VERY_LOW * 8 + + gas_costs.G_COPY, + Op.SUB(Op.MLOAD(mem_gas), Op.GAS), ), - ) + Op.RETURN(0, 32) - inner_address = pre.deploy_contract(inner_contract) - - outer_contract = ( - # Allocate almost the entire memory, so next allocation of 32 bytes - # OOMs if exceed is True. - Op.MLOAD(offset - 32) - + Op.SSTORE(slot_code_worked, value_code_worked) - + Op.SSTORE(slot_call_result, 123) - + Op.SSTORE(slot_all_gas_consumed, 123) - + Op.DELEGATECALL( - gas=inner_gas, - address=inner_address, - ) - + Op.SSTORE(slot_all_gas_consumed, Op.LT(Op.GAS, gas_threshold)) - + Op.RETURNDATACOPY(0, 0, Op.RETURNDATASIZE) - + Op.SSTORE(slot_call_result, Op.MLOAD(0)) ) - outer_address = pre.deploy_contract(outer_contract, balance=10**18) + + Op.SSTORE(slot_call_result, Op.MLOAD(mem_result)) + + Op.SSTORE(slot_code_worked, value_code_worked) + ) + outer_address = pre.deploy_contract(outer_contract, balance=10**18) - post = { + state_test( + pre=pre, + post={ outer_address: Account( storage={ slot_code_worked: value_code_worked, - slot_call_result: 0, - # oog check comes first so regardless of OOM: - slot_all_gas_consumed: 1, + slot_call_result: 0 if exceed or trigger_oog else 1, + slot_inner_gas_consumed: 1 if trigger_oog else 0, } ) - } - - else: # insufficient_balance - # Test: balance check happens AFTER OOM check. - # Setup: CALL with value > sender balance, ret_offset may cause OOM. - # With exceed=True, OOM check fails first (before balance check). - # With exceed=False, OOM check passes, balance check fails. - warm_account = pre.empty_account() - gas_threshold = gas_limit // 64 - - inner_contract = Op.MSTORE( - 0, - Op.CALL( - gas=0, - address=warm_account, - value=1, - ret_offset=0, - ret_size=32, - ), - ) + Op.RETURN(0, 32) - inner_address = pre.deploy_contract(inner_contract) - - outer_contract = ( - # Allocate almost the entire memory, so next allocation of 32 bytes - # OOMs if exceed is True. - Op.MLOAD(offset - 32) - + Op.BALANCE(warm_account) - + Op.SSTORE(slot_code_worked, value_code_worked) - + Op.SSTORE(slot_call_result, 123) - + Op.SSTORE(slot_all_gas_consumed, 123) - + Op.DELEGATECALL(address=inner_address) - + Op.SSTORE(slot_all_gas_consumed, Op.LT(Op.GAS, gas_threshold)) - + Op.RETURNDATACOPY(0, 0, Op.RETURNDATASIZE) - + Op.SSTORE(slot_call_result, Op.MLOAD(0)) - ) - outer_address = pre.deploy_contract(outer_contract, balance=0) + }, + tx=Transaction( + gas_limit=gas_limit, to=outer_address, sender=pre.fund_eoa() + ), + ) + + +@pytest.mark.parametrize("exceed", [True, False]) +@pytest.mark.parametrize("static_violation", [True, False]) +def test_static_check_after_oom_check( + state_test: StateTestFiller, + pre: Alloc, + exceed: bool, + static_violation: bool, + fork: Fork, +) -> None: + """ + Test that static call violation check happens AFTER OOM check. + + If OOM check runs before static check, OOM happens first when exceed=True. + """ + gas_limit = generous_gas(fork) + offset = ( + Spec.MAX_TX_MEMORY_USAGE if exceed else Spec.MAX_TX_MEMORY_USAGE - 32 + ) + gas_threshold = gas_limit // 64 + warm_account = pre.empty_account() + + # ret_size=32 is allocating the extra 32 bytes to cause OOM if exceed. + inner_contract = Op.CALL( + address=warm_account, value=1 if static_violation else 0, ret_size=32 + ) + inner_address = pre.deploy_contract(inner_contract, balance=1) + + outer_contract = ( + Op.MLOAD(offset - 32) + + Op.POP(Op.BALANCE(warm_account)) + + Op.POP(Op.BALANCE(inner_address)) + + Op.SSTORE(slot_call_result, 123) + + Op.SSTORE(slot_all_gas_consumed, 123) + + Op.SSTORE(slot_call_result, Op.STATICCALL(address=inner_address)) + + Op.SSTORE(slot_all_gas_consumed, Op.LT(Op.GAS, gas_threshold)) + + Op.SSTORE(slot_code_worked, value_code_worked) + ) + outer_address = pre.deploy_contract(outer_contract) - post = { + state_test( + pre=pre, + post={ outer_address: Account( storage={ slot_code_worked: value_code_worked, - slot_call_result: 0, - # in either case not all gas is consumed - slot_all_gas_consumed: 0, + slot_call_result: 0 if exceed or static_violation else 1, + slot_all_gas_consumed: 1 + if static_violation and not exceed + else 0, } ) - } - + }, + tx=Transaction( + gas_limit=gas_limit, to=outer_address, sender=pre.fund_eoa() + ), + ) + + +@pytest.mark.parametrize("exceed", [True, False]) +@pytest.mark.parametrize("out_of_bounds", [True, False]) +def test_returndatacopy_check_after_oom_check( + state_test: StateTestFiller, + pre: Alloc, + exceed: bool, + out_of_bounds: bool, + fork: Fork, +) -> None: + """ + Test that returndatacopy out-of-bounds check happens AFTER OOM check. + + OOM happens first when exceed=True. + """ + gas_limit = generous_gas(fork) + returner_size = 64 + offset = ( + Spec.MAX_TX_MEMORY_USAGE - returner_size + if exceed + else Spec.MAX_TX_MEMORY_USAGE - returner_size - 32 + ) + gas_threshold = gas_limit // 64 + pre.empty_account() + + returner_address = pre.deploy_contract(Op.RETURN(0, returner_size)) + + # ret_size=32 is allocating the extra 32 bytes to cause OOM if exceed. + inner_contract = Op.CALL( + address=Address(0x0111) if out_of_bounds else returner_address + ) + copy_offset = 32 + assert offset + returner_size <= Spec.MAX_TX_MEMORY_USAGE + assert exceed == ( + offset + copy_offset + returner_size > Spec.MAX_TX_MEMORY_USAGE + ) + inner_contract += Op.RETURNDATACOPY(copy_offset, 0, returner_size) + inner_address = pre.deploy_contract(inner_contract) + + outer_contract = ( + Op.MLOAD(offset - 32) + + Op.SSTORE(slot_call_result, 123) + + Op.SSTORE(slot_all_gas_consumed, 123) + + Op.SSTORE(slot_call_result, Op.CALL(address=inner_address)) + + Op.SSTORE(slot_all_gas_consumed, Op.LT(Op.GAS, gas_threshold)) + + Op.SSTORE(slot_code_worked, value_code_worked) + ) + outer_address = pre.deploy_contract(outer_contract) + + state_test( + pre=pre, + post={ + outer_address: Account( + storage={ + slot_code_worked: value_code_worked, + slot_call_result: 0 if exceed or out_of_bounds else 1, + slot_all_gas_consumed: 1 + if out_of_bounds and not exceed + else 0, + } + ) + }, + tx=Transaction( + gas_limit=gas_limit, to=outer_address, sender=pre.fund_eoa() + ), + ) + + +@pytest.mark.parametrize("exceed", [True, False]) +@pytest.mark.parametrize("insufficient_balance", [True, False]) +def test_balance_check_after_oom_check( + state_test: StateTestFiller, + pre: Alloc, + exceed: bool, + insufficient_balance: bool, + fork: Fork, +) -> None: + """ + Test that balance check happens AFTER OOM check. + + If OOM check runs before balance check, OOM happens first when exceed=True. + """ + gas_limit = generous_gas(fork) + offset = ( + Spec.MAX_TX_MEMORY_USAGE if exceed else Spec.MAX_TX_MEMORY_USAGE - 32 + ) + gas_threshold = gas_limit // 64 + + warm_account = pre.empty_account() + # ret_size=32 is allocating the extra 32 bytes to cause OOM if exceed. + inner_contract = Op.CALL( + gas=0, + address=warm_account, + value=1, + ret_size=32, + ) + inner_address = pre.deploy_contract(inner_contract) + + outer_contract = ( + Op.MLOAD(offset - 32) + + Op.POP(Op.BALANCE(warm_account)) + + Op.POP(Op.BALANCE(inner_address)) + + Op.SSTORE(slot_call_result, 123) + + Op.SSTORE(slot_all_gas_consumed, 123) + + Op.SSTORE(slot_call_result, Op.DELEGATECALL(address=inner_address)) + + Op.SSTORE(slot_all_gas_consumed, Op.LT(Op.GAS, gas_threshold)) + + Op.SSTORE(slot_code_worked, value_code_worked) + ) + outer_address = pre.deploy_contract( + outer_contract, balance=0 if insufficient_balance else 1 + ) + + state_test( + pre=pre, + post={ + outer_address: Account( + storage={ + slot_code_worked: value_code_worked, + # outer call fails first if OOM, otherwise outer call ok + slot_call_result: 0 if exceed else 1, + # in either case not all gas is consumed + slot_all_gas_consumed: 0, + } + ) + }, + tx=Transaction( + gas_limit=gas_limit, to=outer_address, sender=pre.fund_eoa() + ), + ) + + +@pytest.mark.parametrize("exceed", [True, False]) +@pytest.mark.parametrize( + "log_opcode", + [ + Op.LOG0, + Op.LOG1, + Op.LOG2, + Op.LOG3, + Op.LOG4, + ], +) +def test_oom_check_ordering_static_log( + state_test: StateTestFiller, + pre: Alloc, + log_opcode: Op, + exceed: bool, + fork: Fork, +) -> None: + """ + Test OOM check placement relative to static mode violation from LOGn. + + LOGn opcodes are not allowed in static context. This test verifies that + the OOM check runs before the static mode violation check. + With exceed=True, OOM occurs first and prevents reaching the LOG check. + With exceed=False, OOM passes and LOG triggers static violation. + """ + gas_limit = generous_gas(fork) + gas_threshold = gas_limit // 64 + + # Size for the LOG operation's memory access + log_size = 32 + + # Outer allocates memory, leaving just enough room (or not) for inner + if exceed: + outer_alloc = Spec.MAX_TX_MEMORY_USAGE - log_size + 1 + else: + outer_alloc = Spec.MAX_TX_MEMORY_USAGE - log_size - 32 + + inner_contract = ( + prepare_stack_memory_opcode(log_opcode, log_size) + log_opcode + ) + inner_address = pre.deploy_contract(inner_contract) + + outer_contract = ( + Op.MLOAD(outer_alloc - 32) + + Op.SSTORE(slot_code_worked, value_code_worked) + + Op.SSTORE(slot_call_result, 123) + + Op.SSTORE(slot_all_gas_consumed, 123) + + Op.SSTORE(slot_call_result, Op.STATICCALL(address=inner_address)) + + Op.SSTORE(slot_all_gas_consumed, Op.LT(Op.GAS, gas_threshold)) + ) + outer_address = pre.deploy_contract(outer_contract) + tx = Transaction( gas_limit=gas_limit, to=outer_address, @@ -1174,6 +1489,286 @@ def test_oom_check_ordering_in_call( state_test( pre=pre, - post=post, + post={ + outer_address: Account( + storage={ + slot_code_worked: value_code_worked, + slot_call_result: 0, + slot_all_gas_consumed: 0 if exceed else 1, + } + ) + }, + tx=tx, + ) + + +@pytest.mark.parametrize("exceed", [True, False]) +@pytest.mark.with_all_create_opcodes +def test_oom_check_ordering_static_create( + state_test: StateTestFiller, + pre: Alloc, + create_opcode: Op, + exceed: bool, + fork: Fork, +) -> None: + """ + Test OOM check placement relative to static mode violation from CREATE. + + CREATE/CREATE2 opcodes are not allowed in static context. This test + verifies that the OOM check runs before the static mode violation check. + With exceed=True, OOM occurs first and prevents reaching the CREATE check. + With exceed=False, OOM passes and CREATE triggers static violation. + """ + gas_limit = generous_gas(fork) + gas_threshold = gas_limit // 64 + size = 32 + + # Outer allocates memory, leaving just enough room (or not) for inner + if exceed: + outer_alloc = Spec.MAX_TX_MEMORY_USAGE - size + 1 + else: + outer_alloc = Spec.MAX_TX_MEMORY_USAGE - size - 32 + + if create_opcode == Op.CREATE: + prepare_stack = Op.PUSH32(size) + Op.PUSH0 + Op.PUSH0 + elif create_opcode == Op.CREATE2: + prepare_stack = Op.PUSH0 + Op.PUSH32(size) + Op.PUSH0 + Op.PUSH0 + + inner_contract = prepare_stack + create_opcode + inner_address = pre.deploy_contract(inner_contract) + + outer_contract = ( + Op.MLOAD(outer_alloc - 32) + + Op.SSTORE(slot_code_worked, value_code_worked) + + Op.SSTORE(slot_call_result, 123) + + Op.SSTORE(slot_all_gas_consumed, 123) + + Op.SSTORE(slot_call_result, Op.STATICCALL(address=inner_address)) + + Op.SSTORE(slot_all_gas_consumed, Op.LT(Op.GAS, gas_threshold)) + ) + outer_address = pre.deploy_contract(outer_contract) + + tx = Transaction( + gas_limit=gas_limit, + to=outer_address, + sender=pre.fund_eoa(), + ) + + state_test( + pre=pre, + post={ + outer_address: Account( + storage={ + slot_code_worked: value_code_worked, + slot_call_result: 0, + slot_all_gas_consumed: 0 if exceed else 1, + } + ) + }, + tx=tx, + ) + + +@pytest.mark.parametrize( + "opcode", + [ + Op.CALLDATACOPY(0, 0, 0), + Op.CODECOPY(0, 0, 0), + Op.MCOPY(0, 0, 0), + Op.SHA3(0, 0), + Op.LOG0(0, 0), + ], +) +def test_zero_length_at_boundary( + state_test: StateTestFiller, + pre: Alloc, + opcode: Opcode, + fork: Fork, +) -> None: + """ + Test that zero-length memory operations don't allocate memory. + + Allocates to MAX, then performs zero-length operation which should + NOT cause OOM. + """ + offset = Spec.MAX_TX_MEMORY_USAGE - 32 + + contract = ( + Op.MLOAD(offset) + + opcode + + Op.SSTORE(slot_code_worked, value_code_worked) + ) + contract_address = pre.deploy_contract(contract) + + tx = Transaction( + gas_limit=generous_gas(fork), + to=contract_address, + sender=pre.fund_eoa(), + ) + + state_test( + pre=pre, + post={ + contract_address: Account( + storage={slot_code_worked: value_code_worked} + ) + }, + tx=tx, + ) + + +@pytest.mark.parametrize_by_fork( + "precompile_address", lambda fork: fork.precompiles() +) +@pytest.mark.parametrize("exceed", [True, False]) +def test_precompile_memory_at_limit( + state_test: StateTestFiller, + pre: Alloc, + precompile_address: int, + exceed: bool, + fork: Fork, +) -> None: + """ + Test precompile calls with memory at limit. + """ + ret_size = 64 + + if exceed: + inner_alloc = Spec.MAX_TX_MEMORY_USAGE - ret_size + 32 + else: + inner_alloc = Spec.MAX_TX_MEMORY_USAGE - ret_size + + inner_contract = Op.MLOAD(inner_alloc - 32) + Op.CALL( + gas=Op.GAS, + address=precompile_address, + args_offset=0, + args_size=inner_alloc, + ret_offset=inner_alloc, + ret_size=ret_size, + ) + inner_address = pre.deploy_contract(inner_contract) + + outer_contract = Op.SSTORE( + slot_code_worked, value_code_worked + ) + Op.SSTORE(slot_call_result, Op.CALL(address=inner_address)) + outer_address = pre.deploy_contract(outer_contract) + + tx = Transaction( + gas_limit=generous_gas(fork), + to=outer_address, + sender=pre.fund_eoa(), + ) + + storage = { + slot_code_worked: value_code_worked, + slot_call_result: 0 if exceed else 1, + } + + state_test( + pre=pre, + post={outer_address: Account(storage=storage)}, + tx=tx, + ) + + +@pytest.mark.parametrize("forward", [True, False]) +@pytest.mark.parametrize("exceed", [True, False]) +def test_mcopy_overlap_at_boundary( + state_test: StateTestFiller, + pre: Alloc, + forward: bool, + exceed: bool, + fork: Fork, +) -> None: + """ + Test MCOPY with overlapping regions near memory limit. + + Tests both forward (dest > src) and backward (src > dest) copies + with overlapping ranges at the memory boundary. + """ + size = 64 + if forward: + if exceed: + src = Spec.MAX_TX_MEMORY_USAGE - size - 16 + dest = Spec.MAX_TX_MEMORY_USAGE - 32 + else: + src = Spec.MAX_TX_MEMORY_USAGE - size - 64 + dest = Spec.MAX_TX_MEMORY_USAGE - size - 32 + # forward & overlaps & ooms when exceed + assert ( + src < dest + and src + size > src + and exceed == (dest + size > Spec.MAX_TX_MEMORY_USAGE) + ) + else: + if exceed: + dest = Spec.MAX_TX_MEMORY_USAGE - size - 16 + src = Spec.MAX_TX_MEMORY_USAGE - 32 + else: + dest = Spec.MAX_TX_MEMORY_USAGE - size - 64 + src = Spec.MAX_TX_MEMORY_USAGE - size - 32 + # backward & overlaps & ooms when exceed + assert ( + dest < src + and dest + size > src + and exceed == (src + size > Spec.MAX_TX_MEMORY_USAGE) + ) + + contract = Op.MCOPY(dest, src, size) + Op.SSTORE( + slot_code_worked, value_code_worked + ) + contract_address = pre.deploy_contract(contract) + + tx = Transaction( + gas_limit=generous_gas(fork), + to=contract_address, + sender=pre.fund_eoa(), + ) + + storage = {} if exceed else {slot_code_worked: value_code_worked} + + state_test( + pre=pre, + post={contract_address: Account(storage=storage)}, + tx=tx, + ) + + +def test_memory_access_without_allocation( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Test that memory allocation doesn't decrease or increase + when it is accessed. + """ + inner_address = pre.deploy_contract(Op.MLOAD(0)) + + outer_contract = ( + Op.MLOAD(Spec.MAX_TX_MEMORY_USAGE - 32) + + Op.MLOAD(0) + + Op.MLOAD(32) + + Op.MLOAD(Spec.MAX_TX_MEMORY_USAGE - 32) + + Op.SSTORE(slot_call_result, Op.CALL(address=inner_address)) + + Op.SSTORE(slot_code_worked, value_code_worked) + ) + outer_address = pre.deploy_contract(outer_contract) + + tx = Transaction( + gas_limit=generous_gas(fork), + to=outer_address, + sender=pre.fund_eoa(), + ) + + state_test( + pre=pre, + post={ + outer_address: Account( + storage={ + slot_code_worked: value_code_worked, + slot_call_result: 0, + } + ), + }, tx=tx, ) From 9d5a8830f0ec1a06e210fc7c17deb067629f2563 Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Tue, 3 Feb 2026 08:48:41 +0100 Subject: [PATCH 5/7] Spec update - OOM is OOG --- .../forks/monad_next/vm/exceptions.py | 11 --- src/ethereum/forks/monad_next/vm/gas.py | 6 +- .../forks/monad_next/vm/interpreter.py | 4 - .../mip3_linear_memory/test_gas_cost.py | 85 +------------------ .../monad_nine/mip3_linear_memory/test_oom.py | 44 +++++++--- 5 files changed, 37 insertions(+), 113 deletions(-) diff --git a/src/ethereum/forks/monad_next/vm/exceptions.py b/src/ethereum/forks/monad_next/vm/exceptions.py index 82452af8e3..1a75522a0b 100644 --- a/src/ethereum/forks/monad_next/vm/exceptions.py +++ b/src/ethereum/forks/monad_next/vm/exceptions.py @@ -32,17 +32,6 @@ class Revert(EthereumException): pass -class RevertOnOOM(EthereumException): - """ - Raised when transaction memory limit is exceeded. - - Unlike other EVM exceptions this does not result in the consumption of all - gas. - """ - - pass - - class RevertOnReserveBalance(EthereumException): """ Raised when reserve balance is violated by a transaction. diff --git a/src/ethereum/forks/monad_next/vm/gas.py b/src/ethereum/forks/monad_next/vm/gas.py index 2cf3291716..77fe1fe964 100644 --- a/src/ethereum/forks/monad_next/vm/gas.py +++ b/src/ethereum/forks/monad_next/vm/gas.py @@ -22,7 +22,7 @@ from ..blocks import Header from ..transactions import BlobTransaction, Transaction from . import Evm, EvmMemory -from .exceptions import OutOfGasError, RevertOnOOM +from .exceptions import OutOfGasError GAS_JUMPDEST = Uint(1) GAS_BASE = Uint(2) @@ -223,13 +223,13 @@ def update_memory_high_watermark( Raises ------ - RevertOnOOM + OutOfGasError If the new memory size would exceed MAX_TX_MEMORY_USAGE. """ evm.memory.high_watermark_bytes += int(extend_memory.expand_by) if evm.memory.high_watermark_bytes > MAX_TX_MEMORY_USAGE: - raise RevertOnOOM + raise OutOfGasError def calculate_message_call_gas( diff --git a/src/ethereum/forks/monad_next/vm/interpreter.py b/src/ethereum/forks/monad_next/vm/interpreter.py index 512f766eae..685f80307e 100644 --- a/src/ethereum/forks/monad_next/vm/interpreter.py +++ b/src/ethereum/forks/monad_next/vm/interpreter.py @@ -63,7 +63,6 @@ InvalidOpcode, OutOfGasError, Revert, - RevertOnOOM, RevertOnReserveBalance, StackDepthLimitError, ) @@ -315,9 +314,6 @@ def process_message(message: Message) -> Evm: except Revert as error: evm_trace(evm, OpException(error)) evm.error = error - except RevertOnOOM as error: - evm_trace(evm, OpException(error)) - evm.error = error if evm.error: # revert state to the last saved checkpoint diff --git a/tests/monad_nine/mip3_linear_memory/test_gas_cost.py b/tests/monad_nine/mip3_linear_memory/test_gas_cost.py index f52fd5bfc9..83bb4f8c2f 100644 --- a/tests/monad_nine/mip3_linear_memory/test_gas_cost.py +++ b/tests/monad_nine/mip3_linear_memory/test_gas_cost.py @@ -142,7 +142,6 @@ def memory_sizes( yield pytest.param(0x2000, id="above_quadratic_threshold_copy") if fork >= MONAD_NEXT: yield pytest.param(Spec.MAX_TX_MEMORY_USAGE, id="max") - yield pytest.param(Spec.MAX_TX_MEMORY_USAGE + 32, id="exceed") def memory_copy_opcodes_with_size( @@ -167,10 +166,7 @@ def memory_copy_opcodes_with_size( for opcode, warm_gas, cold_gas in memory_copy_opcodes(fork): for size_param in memory_sizes(fork): - if opcode in exclude_max_opcodes and size_param.id in [ - "max", - "exceed", - ]: + if opcode in exclude_max_opcodes and size_param.id == "max": continue yield pytest.param( opcode, @@ -454,82 +450,3 @@ def test_consecutive_expansions( cold_gas=total_gas, warm_gas=total_gas, ) - - -@pytest.mark.valid_from("MONAD_NEXT") -@pytest.mark.parametrize( - "opcode", - [Op.EXTCODECOPY, Op.CALL, Op.DELEGATECALL, Op.STATICCALL, Op.CALLCODE], -) -@pytest.mark.parametrize_by_fork( - "size", - lambda fork: ( - p - for p in memory_sizes(fork) - if p.values[0] >= Spec.MAX_TX_MEMORY_USAGE - ), -) -@pytest.mark.parametrize( - "initial_memory", - [bytes(range(0x00, 0x100)), bytes()], - ids=["from_existent_memory", "from_empty_memory"], -) -def test_oom_account_stays_cold( - state_test: StateTestFiller, - pre: Alloc, - opcode: Opcode, - fork: Fork, - size: int, - initial_memory: bytes, -) -> None: - """ - Test that OOM reverts account warming for cold access opcodes. - - For "max" size (no OOM): account warms, warm_gas < cold_gas - For "exceed" size (OOM): account stays cold, warm_gas = cold_gas - """ - gas_costs = fork.gas_costs() - cost_memory_bytes = fork.memory_expansion_gas_calculator() - - memory_expansion_cost = cost_memory_bytes( - new_bytes=size, - previous_bytes=len(initial_memory), - ) - - if opcode == Op.EXTCODECOPY: - dynamic_gas_cost = gas_costs.G_COPY * ((size + 31) // 32) - else: - dynamic_gas_cost = 0 - - cold_gas = ( - gas_costs.G_COLD_ACCOUNT_ACCESS - + dynamic_gas_cost - + memory_expansion_cost - ) - - # For OOM (exceed), account stays cold so warm_gas = cold_gas - # For no OOM (max), account warms so warm_gas uses warm access cost - if size > Spec.MAX_TX_MEMORY_USAGE: - warm_gas = cold_gas - else: - warm_gas = ( - gas_costs.G_WARM_ACCOUNT_ACCESS - + dynamic_gas_cost - + memory_expansion_cost - ) - - setup_code = Op.CALLDATACOPY( - 0x00, 0x00, len(initial_memory) - ) + prepare_stack_memory_opcode(opcode, size) - - gas_test( - fork=fork, - state_test=state_test, - pre=pre, - setup_code=setup_code, - subject_code=opcode, - tear_down_code=Op.STOP, - cold_gas=cold_gas, - warm_gas=warm_gas, - out_of_gas_testing=False, - ) diff --git a/tests/monad_nine/mip3_linear_memory/test_oom.py b/tests/monad_nine/mip3_linear_memory/test_oom.py index 5afc22ba35..eab59386da 100644 --- a/tests/monad_nine/mip3_linear_memory/test_oom.py +++ b/tests/monad_nine/mip3_linear_memory/test_oom.py @@ -214,7 +214,7 @@ def test_nested_call_oom( "callee_code,expected_call_result,consumes_all_gas", [ pytest.param(Op.STOP, 1, False, id="success"), - pytest.param(Op.MLOAD(Spec.MAX_TX_MEMORY_USAGE), 0, False, id="oom"), + pytest.param(Op.MLOAD(Spec.MAX_TX_MEMORY_USAGE), 0, True, id="oom"), pytest.param(Op.MLOAD(2**256 - 1), 0, True, id="oog_before_oom"), pytest.param(Op.REVERT(0, 0), 0, False, id="revert"), pytest.param(Op.INVALID, 0, True, id="invalid"), @@ -230,7 +230,9 @@ def test_nested_call_gas_consumption( ) -> None: """ Test gas consumption behavior of CALL with different callee outcomes. - OOM should not consume all gas, unlike OOG and INVALID. + OOM should consume all gas, like OOG and INVALID. + + NOTE: OOM is indisinguishable from OOG """ inner_address = pre.deploy_contract(callee_code) @@ -631,7 +633,14 @@ def test_inner_frame_incremental_memory_allocation( # store size to allocate in the inner frame, minus # size of MLOAD allocation, minus current allocation + Op.MSTORE(0, Op.SUB(size - 32, Op.MSIZE)) - + Op.SSTORE(size, Op.CALL(address=inner_address, args_size=32)) + + Op.SSTORE( + size, + Op.CALL( + gas=Op.DIV(Op.GAS, len(sizes)), + address=inner_address, + args_size=32, + ), + ) ) + outer outer_address = pre.deploy_contract(outer) @@ -1128,6 +1137,8 @@ def test_charge_gas_before_oom_check( gas costs), with a return buffer that causes OOM if exceed=True. If charge_gas runs before OOM check, OOG happens regardless of exceed. + + NOTE: OOM is indisinguishable from OOG """ gas_limit = generous_gas(fork) gas_costs = fork.gas_costs() @@ -1230,7 +1241,8 @@ def test_charge_gas_before_oom_check( storage={ slot_code_worked: value_code_worked, slot_call_result: 0 if exceed or trigger_oog else 1, - slot_inner_gas_consumed: 1 if trigger_oog else 0, + # OOM indistinguishable from OOG + slot_inner_gas_consumed: 1 if exceed or trigger_oog else 0, } ) }, @@ -1253,6 +1265,8 @@ def test_static_check_after_oom_check( Test that static call violation check happens AFTER OOM check. If OOM check runs before static check, OOM happens first when exceed=True. + + NOTE: OOM is indisinguishable from OOG """ gas_limit = generous_gas(fork) offset = ( @@ -1286,8 +1300,9 @@ def test_static_check_after_oom_check( storage={ slot_code_worked: value_code_worked, slot_call_result: 0 if exceed or static_violation else 1, + # OOM indistinguishable from OOG slot_all_gas_consumed: 1 - if static_violation and not exceed + if exceed or static_violation else 0, } ) @@ -1311,6 +1326,8 @@ def test_returndatacopy_check_after_oom_check( Test that returndatacopy out-of-bounds check happens AFTER OOM check. OOM happens first when exceed=True. + + NOTE: OOM is indisinguishable from OOG """ gas_limit = generous_gas(fork) returner_size = 64 @@ -1353,9 +1370,8 @@ def test_returndatacopy_check_after_oom_check( storage={ slot_code_worked: value_code_worked, slot_call_result: 0 if exceed or out_of_bounds else 1, - slot_all_gas_consumed: 1 - if out_of_bounds and not exceed - else 0, + # OOM indistinguishable from OOG + slot_all_gas_consumed: 1 if exceed or out_of_bounds else 0, } ) }, @@ -1418,7 +1434,7 @@ def test_balance_check_after_oom_check( # outer call fails first if OOM, otherwise outer call ok slot_call_result: 0 if exceed else 1, # in either case not all gas is consumed - slot_all_gas_consumed: 0, + slot_all_gas_consumed: 1 if exceed else 0, } ) }, @@ -1453,6 +1469,8 @@ def test_oom_check_ordering_static_log( the OOM check runs before the static mode violation check. With exceed=True, OOM occurs first and prevents reaching the LOG check. With exceed=False, OOM passes and LOG triggers static violation. + + NOTE: OOM is indisinguishable from OOG """ gas_limit = generous_gas(fork) gas_threshold = gas_limit // 64 @@ -1494,7 +1512,8 @@ def test_oom_check_ordering_static_log( storage={ slot_code_worked: value_code_worked, slot_call_result: 0, - slot_all_gas_consumed: 0 if exceed else 1, + # OOM indistinguishable from OOG + slot_all_gas_consumed: 1, } ) }, @@ -1518,6 +1537,8 @@ def test_oom_check_ordering_static_create( verifies that the OOM check runs before the static mode violation check. With exceed=True, OOM occurs first and prevents reaching the CREATE check. With exceed=False, OOM passes and CREATE triggers static violation. + + NOTE: OOM is indisinguishable from OOG """ gas_limit = generous_gas(fork) gas_threshold = gas_limit // 64 @@ -1560,7 +1581,8 @@ def test_oom_check_ordering_static_create( storage={ slot_code_worked: value_code_worked, slot_call_result: 0, - slot_all_gas_consumed: 0 if exceed else 1, + # OOM indistinguishable from OOG + slot_all_gas_consumed: 1, } ) }, From 729d44a06954643ecd78835146e1aa2a606f4e6b Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Fri, 13 Feb 2026 12:26:12 +0100 Subject: [PATCH 6/7] Fix mypy --- .../execution_testing/forks/forks/forks.py | 1 + src/ethereum/forks/monad_next/vm/gas.py | 4 +-- .../forks/monad_next/vm/interpreter.py | 2 +- .../mip3_linear_memory/test_gas_cost.py | 2 +- .../monad_nine/mip3_linear_memory/test_oom.py | 26 +++++++++---------- .../mip3_linear_memory/test_oom_deep.py | 5 +++- 6 files changed, 22 insertions(+), 18 deletions(-) diff --git a/packages/testing/src/execution_testing/forks/forks/forks.py b/packages/testing/src/execution_testing/forks/forks/forks.py index 2fbe248dd4..b8e7440546 100644 --- a/packages/testing/src/execution_testing/forks/forks/forks.py +++ b/packages/testing/src/execution_testing/forks/forks/forks.py @@ -3408,6 +3408,7 @@ def memory_expansion_gas_calculator( Return callable that calculates the gas cost of memory expansion for the fork. """ + del block_number, timestamp memory_words_per_gas = 2 def fn(*, new_bytes: int, previous_bytes: int = 0) -> int: diff --git a/src/ethereum/forks/monad_next/vm/gas.py b/src/ethereum/forks/monad_next/vm/gas.py index 77fe1fe964..64ba254f91 100644 --- a/src/ethereum/forks/monad_next/vm/gas.py +++ b/src/ethereum/forks/monad_next/vm/gas.py @@ -78,11 +78,11 @@ TARGET_BLOB_GAS_PER_BLOCK = GAS_PER_BLOB * BLOB_SCHEDULE_TARGET BLOB_BASE_COST = Uint(2**13) BLOB_SCHEDULE_MAX = U64(9) +MIN_BLOB_GASPRICE = Uint(1) +BLOB_BASE_FEE_UPDATE_FRACTION = Uint(5007716) MAX_TX_MEMORY_USAGE = 8 * 1024 * 1024 MEMORY_WORDS_PER_GAS = Uint(2) -MIN_BLOB_GASPRICE = Uint(1) -BLOB_BASE_FEE_UPDATE_FRACTION = Uint(5007716) GAS_BLS_G1_ADD = Uint(375) GAS_BLS_G1_MUL = Uint(12000) diff --git a/src/ethereum/forks/monad_next/vm/interpreter.py b/src/ethereum/forks/monad_next/vm/interpreter.py index 685f80307e..0eb2369253 100644 --- a/src/ethereum/forks/monad_next/vm/interpreter.py +++ b/src/ethereum/forks/monad_next/vm/interpreter.py @@ -18,7 +18,6 @@ from ethereum_types.numeric import U256, Uint, ulen from ethereum.exceptions import EthereumException -from ethereum.forks.monad_next.vm.memory import EvmMemory from ethereum.trace import ( EvmStop, OpEnd, @@ -67,6 +66,7 @@ StackDepthLimitError, ) from .instructions import Ops, op_implementation +from .memory import EvmMemory from .runtime import get_valid_jump_destinations STACK_DEPTH_LIMIT = Uint(1024) diff --git a/tests/monad_nine/mip3_linear_memory/test_gas_cost.py b/tests/monad_nine/mip3_linear_memory/test_gas_cost.py index 83bb4f8c2f..5cfc8fca4a 100644 --- a/tests/monad_nine/mip3_linear_memory/test_gas_cost.py +++ b/tests/monad_nine/mip3_linear_memory/test_gas_cost.py @@ -74,7 +74,7 @@ def test_cost_non_quadratic( def memory_copy_opcodes( fork: Fork, -) -> Generator[ParameterSet, None, None]: +) -> Generator[tuple[Op, int, int], None, None]: """ Memory-reading opcodes which allocate memory. Includes copy, hashing, and logging opcodes. diff --git a/tests/monad_nine/mip3_linear_memory/test_oom.py b/tests/monad_nine/mip3_linear_memory/test_oom.py index eab59386da..d35fee627d 100644 --- a/tests/monad_nine/mip3_linear_memory/test_oom.py +++ b/tests/monad_nine/mip3_linear_memory/test_oom.py @@ -3,6 +3,7 @@ """ import itertools +from typing import List import pytest from execution_testing import ( @@ -306,28 +307,25 @@ def test_nested_create_oom( to=factory_address, sender=pre.fund_eoa(), ) - - factory_storage = { - slot_code_worked: value_code_worked, - } new_contract_address = compute_create_address( address=factory_address, nonce=1, initcode=initcode, opcode=create_opcode, ) - if exceed: - factory_storage[slot_call_result] = 0 - new_contract = Account.NONEXISTENT - else: - factory_storage[slot_call_result] = new_contract_address - new_contract = Account(code=b"") + + factory_storage = { + slot_code_worked: value_code_worked, + slot_call_result: 0 if exceed else new_contract_address, + } state_test( pre=pre, post={ factory_address: Account(storage=factory_storage), - new_contract_address: new_contract, + new_contract_address: Account.NONEXISTENT + if exceed + else Account(code=b""), }, tx=tx, ) @@ -356,7 +354,7 @@ def test_top_level_oom_creation_tx( to=None, ty=tx_type, sender=pre.fund_eoa(), - input=initcode, + data=initcode, ) state_test( @@ -515,7 +513,7 @@ def test_nested_frames_oom( chunk_size = (Spec.MAX_TX_MEMORY_USAGE // exceeds_at_depth) + 64 # Deploy contracts from deepest to shallowest - addresses = [] + addresses: List[Address] = [] for depth in range(exceeds_at_depth - 1, -1, -1): callee = addresses[-1] if addresses else Address(0x0) contract = ( @@ -1143,6 +1141,8 @@ def test_charge_gas_before_oom_check( gas_limit = generous_gas(fork) gas_costs = fork.gas_costs() + target: Address + if gas_cost_type == "account_create": target = pre.fund_eoa(amount=0 if trigger_oog else 1) call_value = 1 diff --git a/tests/monad_nine/mip3_linear_memory/test_oom_deep.py b/tests/monad_nine/mip3_linear_memory/test_oom_deep.py index 47419d4f51..f4ee9b6eb3 100644 --- a/tests/monad_nine/mip3_linear_memory/test_oom_deep.py +++ b/tests/monad_nine/mip3_linear_memory/test_oom_deep.py @@ -6,6 +6,8 @@ - MONAD_EIGHT: can go above this limit if allocations spread across frames """ +from typing import List + import pytest from execution_testing import ( Account, @@ -14,6 +16,7 @@ StateTestFiller, Transaction, ) +from execution_testing.base_types.base_types import Address from execution_testing.forks.forks.forks import MONAD_NEXT from execution_testing.forks.helpers import Fork @@ -58,7 +61,7 @@ def test_nested_frames_deep( ) # Deploy contracts from deepest to shallowest - addresses = [] + addresses: List[Address] = [] for depth in range(max_depth - 1, -1, -1): if depth == max_depth - 1: # Deepest level: allocate memory and store success From f7c09a0059e288ae7ff338be0b746b6ab5d6d8bc Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Mon, 16 Feb 2026 09:38:17 +0000 Subject: [PATCH 7/7] Fix greptile issues around high watermark --- src/ethereum/forks/monad_next/vm/__init__.py | 14 ++++++++++++++ src/ethereum/forks/monad_next/vm/gas.py | 4 +++- .../evm_tools/t8n/evm_trace/eip3155.py | 8 ++------ 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/ethereum/forks/monad_next/vm/__init__.py b/src/ethereum/forks/monad_next/vm/__init__.py index 19e6ebfba6..b70867cd42 100644 --- a/src/ethereum/forks/monad_next/vm/__init__.py +++ b/src/ethereum/forks/monad_next/vm/__init__.py @@ -145,6 +145,14 @@ class EvmMemory: data: bytearray high_watermark_bytes: int + def __len__(self) -> int: + """Return the length of the memory data.""" + return len(self.data) + + def hex(self) -> str: + """Return the hex string of the memory data.""" + return self.data.hex() + @dataclass class Evm: @@ -187,6 +195,9 @@ def incorporate_child_on_success(evm: Evm, child_evm: Evm) -> None: evm.accessed_addresses.update(child_evm.accessed_addresses) evm.accessed_storage_keys.update(child_evm.accessed_storage_keys) + # NOTE: absence of `evm.memory`, in particular of its high watermark + # is intended for memory to deallocate on call frame exit. + def incorporate_child_on_error(evm: Evm, child_evm: Evm) -> None: """ @@ -201,3 +212,6 @@ def incorporate_child_on_error(evm: Evm, child_evm: Evm) -> None: """ evm.gas_left += child_evm.gas_left + + # NOTE: absence of `evm.memory`, in particular of its high watermark + # is intended for memory to deallocate on call frame exit. diff --git a/src/ethereum/forks/monad_next/vm/gas.py b/src/ethereum/forks/monad_next/vm/gas.py index 64ba254f91..f3cf5d837a 100644 --- a/src/ethereum/forks/monad_next/vm/gas.py +++ b/src/ethereum/forks/monad_next/vm/gas.py @@ -212,7 +212,9 @@ def update_memory_high_watermark( ) -> None: """ Update the memory high watermark and check it doesn't exceed - MAX_TX_MEMORY_USAGE. + MAX_TX_MEMORY_USAGE. The high watermark is intended to not be + propagated from child EVM call frame to its parent, as the memory + is respectively deallocated on exit. Parameters ---------- diff --git a/src/ethereum_spec_tools/evm_tools/t8n/evm_trace/eip3155.py b/src/ethereum_spec_tools/evm_tools/t8n/evm_trace/eip3155.py index 281a9a686f..9e89598532 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/evm_trace/eip3155.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/evm_trace/eip3155.py @@ -124,11 +124,7 @@ def __call__(self, evm: Any, event: TraceEvent) -> None: refund_counter += parent_evm.refund_counter parent_evm = parent_evm.message.parent_evm - evm_memory_data = ( - evm.memory.data if hasattr(evm.memory, "data") else evm.memory - ) - - len_memory = len(evm_memory_data) + len_memory = len(evm.memory) return_data = None if isinstance(evm, EvmWithReturnData) and self.trace_return_data: @@ -136,7 +132,7 @@ def __call__(self, evm: Any, event: TraceEvent) -> None: memory = None if self.trace_memory and len_memory > 0: - memory = "0x" + evm_memory_data.hex() + memory = "0x" + evm.memory.hex() stack = None if self.trace_stack: