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" diff --git a/packages/testing/src/execution_testing/forks/forks/forks.py b/packages/testing/src/execution_testing/forks/forks/forks.py index 15c70f80df..b8e7440546 100644 --- a/packages/testing/src/execution_testing/forks/forks/forks.py +++ b/packages/testing/src/execution_testing/forks/forks/forks.py @@ -3400,6 +3400,30 @@ 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. + """ + del block_number, timestamp + memory_words_per_gas = 2 + + 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 w // memory_words_per_gas + + 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..b70867cd42 100644 --- a/src/ethereum/forks/monad_next/vm/__init__.py +++ b/src/ethereum/forks/monad_next/vm/__init__.py @@ -136,13 +136,31 @@ class Message: disable_create_opcodes: bool +@dataclass +class EvmMemory: + """ + Memory of the EVM. + """ + + 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: """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] @@ -177,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: """ @@ -191,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 0e06aba9b3..f3cf5d837a 100644 --- a/src/ethereum/forks/monad_next/vm/gas.py +++ b/src/ethereum/forks/monad_next/vm/gas.py @@ -21,7 +21,7 @@ from ..blocks import Header from ..transactions import BlobTransaction, Transaction -from . import Evm +from . import Evm, EvmMemory from .exceptions import OutOfGasError GAS_JUMPDEST = Uint(1) @@ -81,6 +81,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) + GAS_BLS_G1_ADD = Uint(375) GAS_BLS_G1_MUL = Uint(12000) GAS_BLS_G1_MAP = Uint(5500) @@ -160,17 +163,12 @@ 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 - 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( - memory: bytearray, extensions: List[Tuple[U256, U256]] + memory: EvmMemory, extensions: List[Tuple[U256, U256]] ) -> ExtendMemory: """ Calculates the gas amount to extend memory. @@ -178,7 +176,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 +188,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 +207,33 @@ 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. 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 + ---------- + evm : + The EVM object. + extend_memory : + The memory extension info from calculate_gas_extend_memory. + + Raises + ------ + 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 OutOfGasError + + def calculate_message_call_gas( value: U256, gas: Uint, @@ -245,11 +270,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..0eb2369253 100644 --- a/src/ethereum/forks/monad_next/vm/interpreter.py +++ b/src/ethereum/forks/monad_next/vm/interpreter.py @@ -66,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) @@ -250,10 +251,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, 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/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/__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..8303fd834c --- /dev/null +++ b/tests/monad_nine/mip3_linear_memory/helpers.py @@ -0,0 +1,146 @@ +""" +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 + +COLD_ACCESS_TARGET_ADDRESS = 0x1234567890ABCDEF1234567890ABCDEF12345678 + + +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 + 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 + 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 + return ( + Op.PUSH32(size) + + Op.PUSH0 # retSize, retOffset + + Op.PUSH0 + + Op.PUSH0 # argsSize, argsOffset + + Op.PUSH0 # value + + Op.PUSH20(COLD_ACCESS_TARGET_ADDRESS) + + Op.GAS # use all available gas + ) + elif opcode == Op.CALLCODE: + # stack: gas, address, value, argsOffset, argsSize, retOffset, retSize + return ( + Op.PUSH32(size) + + Op.PUSH0 # retSize, retOffset + + Op.PUSH0 + + Op.PUSH0 # argsSize, argsOffset + + Op.PUSH0 # value + + Op.PUSH20(COLD_ACCESS_TARGET_ADDRESS) + + Op.GAS # use all available gas + ) + elif opcode == Op.DELEGATECALL: + # stack: gas, address, argsOffset, argsSize, retOffset, retSize + return ( + Op.PUSH32(size) + + Op.PUSH0 # retSize, retOffset + + Op.PUSH0 + + Op.PUSH0 # argsSize, argsOffset + + Op.PUSH20(COLD_ACCESS_TARGET_ADDRESS) + + Op.GAS # use all available gas + ) + elif opcode == Op.STATICCALL: + # stack: gas, address, argsOffset, argsSize, retOffset, retSize + return ( + Op.PUSH32(size) + + Op.PUSH0 # retSize, retOffset + + Op.PUSH0 + + Op.PUSH0 # argsSize, argsOffset + + Op.PUSH20(COLD_ACCESS_TARGET_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 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 new file mode 100644 index 0000000000..5137380629 --- /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", "a70093b2549d935e31accd62e2e784114253fdc2" +) + + +@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..5cfc8fca4a --- /dev/null +++ b/tests/monad_nine/mip3_linear_memory/test_gas_cost.py @@ -0,0 +1,452 @@ +""" +Tests linear gas cost of the MIP-3 memory model. +""" + +from typing import Generator + +import pytest +from execution_testing import ( + Account, + Alloc, + Bytecode, + 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 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.pre_alloc_group( + "mip3_tests", + reason="Tests linear memory MIP-3", + ), +] + + +@pytest.mark.valid_from("MONAD_EIGHT") +@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[tuple[Op, int, int], None, None]: + """ + Memory-reading opcodes which allocate memory. + Includes copy, hashing, and logging 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, + # RETURNDATACOPY tested separately in test_returndatacopy_gas_cost + } + + 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( + fork: Fork, +) -> Generator[ParameterSet, None, None]: + """ + Stack-memory opcodes which always read at least 1 byte or 1 word. + """ + 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, warm_gas in memory_opcodes.items(): + cold_gas = warm_gas + yield pytest.param(opcode, warm_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.valid_from("MONAD_EIGHT") +@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, + ): + 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, + 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 + # 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 +) +@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, + 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, + ) 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..d35fee627d --- /dev/null +++ b/tests/monad_nine/mip3_linear_memory/test_oom.py @@ -0,0 +1,1796 @@ +""" +Tests OOM (out-of-memory) behavior of the MIP-3 memory model. +""" + +import itertools +from typing import List + +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_inner_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, 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"), + ], +) +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. + OOM should consume all gas, like OOG and INVALID. + + NOTE: OOM is indisinguishable from OOG + """ + 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, + ) + + +@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(), + ) + new_contract_address = compute_create_address( + address=factory_address, + nonce=1, + initcode=initcode, + opcode=create_opcode, + ) + + 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: Account.NONEXISTENT + if exceed + else Account(code=b""), + }, + 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(), + data=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. + + RETURNDATACOPY tested separately. + """ + # 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("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, + 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: List[Address] = [] + 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( + "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( + gas=Op.DIV(Op.GAS, len(sizes)), + 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", + [ + 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("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", + [ + 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("trigger_oog", [True, False]) +@pytest.mark.parametrize( + "gas_cost_type", + ["account_create", "value_transfer", "access_cost", "memory_expansion"], +) +def test_charge_gas_before_oom_check( + state_test: StateTestFiller, + pre: Alloc, + gas_cost_type: str, + exceed: bool, + trigger_oog: bool, + fork: Fork, +) -> None: + """ + Test that charge_gas happens BEFORE OOM check in CALL opcode. + + 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. + + 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() + + target: Address + + 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 + ) + 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 + ) + warm_target = True + elif gas_cost_type == "access_cost": + target = pre.empty_account() + call_value = 0 + inner_offset = 32 + inner_gas = ( + 7 * gas_costs.G_VERY_LOW + + gas_costs.G_COLD_ACCOUNT_ACCESS + + fork.memory_expansion_gas_calculator()(new_bytes=inner_offset) + - 1 + ) + 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 + ) + warm_target = True + else: + raise Exception(f"Unknown scenario: {gas_cost_type}") + + # 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.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) + + state_test( + pre=pre, + post={ + outer_address: Account( + storage={ + slot_code_worked: value_code_worked, + slot_call_result: 0 if exceed or trigger_oog else 1, + # OOM indistinguishable from OOG + slot_inner_gas_consumed: 1 if exceed or trigger_oog else 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. + + NOTE: OOM is indisinguishable from OOG + """ + 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) + + state_test( + pre=pre, + post={ + outer_address: Account( + 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 exceed or static_violation + 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. + + NOTE: OOM is indisinguishable from OOG + """ + 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, + # OOM indistinguishable from OOG + slot_all_gas_consumed: 1 if exceed or out_of_bounds 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: 1 if 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( + "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. + + NOTE: OOM is indisinguishable from OOG + """ + 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, + sender=pre.fund_eoa(), + ) + + state_test( + pre=pre, + post={ + outer_address: Account( + storage={ + slot_code_worked: value_code_worked, + slot_call_result: 0, + # OOM indistinguishable from OOG + slot_all_gas_consumed: 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. + + NOTE: OOM is indisinguishable from OOG + """ + 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, + # OOM indistinguishable from OOG + slot_all_gas_consumed: 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, + ) 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..f4ee9b6eb3 --- /dev/null +++ b/tests/monad_nine/mip3_linear_memory/test_oom_deep.py @@ -0,0 +1,103 @@ +""" +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 +""" + +from typing import List + +import pytest +from execution_testing import ( + Account, + Alloc, + Op, + 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 + +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: List[Address] = [] + 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, + )