From b0d15c4fb41218671ff100c2c2367b877d91477c Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Tue, 27 Jan 2026 14:49:22 +0000 Subject: [PATCH 1/9] Extract is_reserve_balance_violated function Co-Authored-By: Claude --- .../forks/monad_nine/vm/interpreter.py | 70 +++++++++++-------- 1 file changed, 40 insertions(+), 30 deletions(-) diff --git a/src/ethereum/forks/monad_nine/vm/interpreter.py b/src/ethereum/forks/monad_nine/vm/interpreter.py index 73c43e2e21..a498dbaa7b 100644 --- a/src/ethereum/forks/monad_nine/vm/interpreter.py +++ b/src/ethereum/forks/monad_nine/vm/interpreter.py @@ -32,6 +32,7 @@ from ..blocks import Log from ..fork_types import Address from ..state import ( + State, account_has_code_or_nonce, account_has_storage, begin_transaction, @@ -46,7 +47,7 @@ rollback_transaction, set_code, ) -from ..vm import Message +from ..vm import Message, TransactionEnvironment from ..vm.eoa_delegation import ( get_delegated_code_address, is_valid_delegation, @@ -76,6 +77,40 @@ RESERVE_BALANCE = U256(10 * 10**18) # 10 MON +def is_reserve_balance_violated( + state: State, + tx_env: TransactionEnvironment, +) -> bool: + """ + Check if any EOA has violated the reserve balance constraint. + + Returns True if a violation is detected, False otherwise. + """ + for addr in set(state._main_trie._data.keys()): + acc = get_account(state, addr) + if acc.code == b"" or is_valid_delegation(acc.code): + original_balance = get_balance_original(state, addr) + if tx_env.origin == addr: + # gas_fees already deducted, need to re-add if sender + # to match with spec. + gas_fees = U256(tx_env.gas_price * tx_env.tx_gas_limit) + original_balance += gas_fees + reserve = min(RESERVE_BALANCE, original_balance) + threshold = reserve - gas_fees + else: + threshold = RESERVE_BALANCE + is_exception = not is_sender_authority( + state, addr + ) and not is_valid_delegation(acc.code) + if ( + acc.balance < original_balance + and acc.balance < threshold + and not is_exception + ): + return True + return False + + @dataclass class MessageCallOutput: """ @@ -322,34 +357,9 @@ def process_message(message: Message) -> Evm: else: # FIXME: index_in_block is a proxy for not being a system tx if message.depth == 0 and message.tx_env.index_in_block is not None: - for addr in set(state._main_trie._data.keys()): - acc = get_account(state, addr) - if acc.code == b"" or is_valid_delegation(acc.code): - original_balance = get_balance_original(state, addr) - if message.tx_env.origin == addr: - # gas_fees already deducted, need to re-add if sender - # to match with spec. - gas_fees = U256( - message.tx_env.gas_price - * message.tx_env.tx_gas_limit - ) - original_balance += gas_fees - reserve = min(RESERVE_BALANCE, original_balance) - threshold = reserve - gas_fees - else: - threshold = RESERVE_BALANCE - is_exception = not is_sender_authority( - state, addr - ) and not is_valid_delegation(acc.code) - if ( - acc.balance < original_balance - and acc.balance < threshold - and not is_exception - ): - rollback_transaction(state, transient_storage) - evm.error = RevertOnReserveBalance() - return evm - # cannot do this because it fails the entire tx - # raise RevertOnReserveBalance + if is_reserve_balance_violated(state, message.tx_env): + rollback_transaction(state, transient_storage) + evm.error = RevertOnReserveBalance() + return evm commit_transaction(state, transient_storage) return evm From 31173b9b159c1f333db66f7c180ba465606a3508 Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Fri, 6 Mar 2026 11:16:02 +0100 Subject: [PATCH 2/9] feat(tests): RESERVE_BALANCE precompile with tests Co-Authored-By: Claude claude-opus-4-5-20251101 Squashed from: Gas-dependent error message for `method not supported` Tidy spec.py Fix check order to match post PR 2109 spec Appease mypy refactor _is_call in reserve_balance.py remove early version of test_check_order test Add check-order tests and refactor precompile test helpers Generic precompile tests aware of Monad precompiles Test essential usecase of containing reserve balance violation Enusre revert returns are independent of gas Spec update: add error messages to reserve balance precompile Appease mypy Add SHORT_CALLDATA to CallScenario Refactor and expand precompile call tests Spec update: precompile gas cost GAS_BASE -> GAS_WARM_ACCESS (100) Spec update: reject nonzero value in reserve balance precompile Add test_contract_unrestricted_with_selfdestruct wip claude 16: mip4 test_many_accounts_balance_change optimizations wip claude 14: MIP-4 spec update - strict calldata and CALL-only wip claude 13 (squashed): tests: 2step, uint256max, regression wip claude 10: simplify RefillFactory to return callable directly wip claude 08: convert CHECKRESERVEBALANCE opcode to precompile wip claude 03 (squashed): adding tests wip claude 03: add MIP-4 CHECKRESERVEBALANCE opcode tests (preliminary) wip claude 02: add CHECKRESERVEBALANCE opcode (MIP-4) --- .../execution_testing/forks/forks/forks.py | 8 +- .../forks/monad_nine/vm/exceptions.py | 11 + .../forks/monad_nine/vm/interpreter.py | 6 + .../vm/precompiled_contracts/__init__.py | 2 + .../vm/precompiled_contracts/mapping.py | 3 + .../precompiled_contracts/reserve_balance.py | 105 ++ tests/monad_nine/__init__.py | 1 + .../mip4_checkreservebalance/__init__.py | 1 + .../mip4_checkreservebalance/conftest.py | 45 + .../mip4_checkreservebalance/helpers.py | 54 + .../mip4_checkreservebalance/spec.py | 38 + .../test_fork_transition.py | 119 ++ .../test_multi_block.py | 185 ++ .../test_precompile_call.py | 699 ++++++++ .../test_transfers.py | 1541 +++++++++++++++++ .../test_tx_revert.py | 118 ++ 16 files changed, 2934 insertions(+), 2 deletions(-) create mode 100644 src/ethereum/forks/monad_nine/vm/precompiled_contracts/reserve_balance.py create mode 100644 tests/monad_nine/__init__.py create mode 100644 tests/monad_nine/mip4_checkreservebalance/__init__.py create mode 100644 tests/monad_nine/mip4_checkreservebalance/conftest.py create mode 100644 tests/monad_nine/mip4_checkreservebalance/helpers.py create mode 100644 tests/monad_nine/mip4_checkreservebalance/spec.py create mode 100644 tests/monad_nine/mip4_checkreservebalance/test_fork_transition.py create mode 100644 tests/monad_nine/mip4_checkreservebalance/test_multi_block.py create mode 100644 tests/monad_nine/mip4_checkreservebalance/test_precompile_call.py create mode 100644 tests/monad_nine/mip4_checkreservebalance/test_transfers.py create mode 100644 tests/monad_nine/mip4_checkreservebalance/test_tx_revert.py diff --git a/packages/testing/src/execution_testing/forks/forks/forks.py b/packages/testing/src/execution_testing/forks/forks/forks.py index 8c3bec9a0f..774382a43d 100644 --- a/packages/testing/src/execution_testing/forks/forks/forks.py +++ b/packages/testing/src/execution_testing/forks/forks/forks.py @@ -3386,8 +3386,12 @@ def max_code_size( def precompiles( cls, *, block_number: int = 0, timestamp: int = 0 ) -> List[Address]: - """Return spec from explicit parent.""" - return MONAD_EIGHT.precompiles( + """ + Return spec from explicit parent plus reserve balance precompile. + """ + return [ + Address(0x1001, label="RESERVE_BALANCE"), + ] + MONAD_EIGHT.precompiles( block_number=block_number, timestamp=timestamp ) diff --git a/src/ethereum/forks/monad_nine/vm/exceptions.py b/src/ethereum/forks/monad_nine/vm/exceptions.py index 1a75522a0b..7c1473772c 100644 --- a/src/ethereum/forks/monad_nine/vm/exceptions.py +++ b/src/ethereum/forks/monad_nine/vm/exceptions.py @@ -134,6 +134,17 @@ class InvalidParameter(ExceptionalHalt): pass +class RevertInMonadPrecompile(ExceptionalHalt): + """ + Raised by a Monad precompile to revert with an error message. + + Consumes all gas like ExceptionalHalt but preserves evm.output + so the caller sees the revert reason. + """ + + pass + + class InvalidContractPrefix(ExceptionalHalt): """ Raised when the new contract code starts with 0xEF. diff --git a/src/ethereum/forks/monad_nine/vm/interpreter.py b/src/ethereum/forks/monad_nine/vm/interpreter.py index a498dbaa7b..4fc7353ea5 100644 --- a/src/ethereum/forks/monad_nine/vm/interpreter.py +++ b/src/ethereum/forks/monad_nine/vm/interpreter.py @@ -63,6 +63,7 @@ InvalidOpcode, OutOfGasError, Revert, + RevertInMonadPrecompile, RevertOnReserveBalance, StackDepthLimitError, ) @@ -341,6 +342,11 @@ def process_message(message: Message) -> Evm: evm_trace(evm, EvmStop(Ops.STOP)) + except RevertInMonadPrecompile as error: + evm_trace(evm, OpException(error)) + evm.gas_left = Uint(0) + # evm.output preserved — contains the raw error message + evm.error = error except ExceptionalHalt as error: evm_trace(evm, OpException(error)) evm.gas_left = Uint(0) diff --git a/src/ethereum/forks/monad_nine/vm/precompiled_contracts/__init__.py b/src/ethereum/forks/monad_nine/vm/precompiled_contracts/__init__.py index d32959fc93..2c9fb48fd6 100644 --- a/src/ethereum/forks/monad_nine/vm/precompiled_contracts/__init__.py +++ b/src/ethereum/forks/monad_nine/vm/precompiled_contracts/__init__.py @@ -33,6 +33,7 @@ "BLS12_MAP_FP_TO_G1_ADDRESS", "BLS12_MAP_FP2_TO_G2_ADDRESS", "P256VERIFY_ADDRESS", + "RESERVE_BALANCE_ADDRESS", ) ECRECOVER_ADDRESS = hex_to_address("0x01") @@ -53,3 +54,4 @@ BLS12_MAP_FP_TO_G1_ADDRESS = hex_to_address("0x10") BLS12_MAP_FP2_TO_G2_ADDRESS = hex_to_address("0x11") P256VERIFY_ADDRESS = hex_to_address("0x100") +RESERVE_BALANCE_ADDRESS = hex_to_address("0x1001") diff --git a/src/ethereum/forks/monad_nine/vm/precompiled_contracts/mapping.py b/src/ethereum/forks/monad_nine/vm/precompiled_contracts/mapping.py index 7486203c3e..e4253b8970 100644 --- a/src/ethereum/forks/monad_nine/vm/precompiled_contracts/mapping.py +++ b/src/ethereum/forks/monad_nine/vm/precompiled_contracts/mapping.py @@ -31,6 +31,7 @@ MODEXP_ADDRESS, P256VERIFY_ADDRESS, POINT_EVALUATION_ADDRESS, + RESERVE_BALANCE_ADDRESS, RIPEMD160_ADDRESS, SHA256_ADDRESS, ) @@ -52,6 +53,7 @@ from .modexp import modexp from .p256verify import p256verify from .point_evaluation import point_evaluation +from .reserve_balance import reserve_balance from .ripemd160 import ripemd160 from .sha256 import sha256 @@ -74,4 +76,5 @@ BLS12_MAP_FP_TO_G1_ADDRESS: bls12_map_fp_to_g1, BLS12_MAP_FP2_TO_G2_ADDRESS: bls12_map_fp2_to_g2, P256VERIFY_ADDRESS: p256verify, + RESERVE_BALANCE_ADDRESS: reserve_balance, } diff --git a/src/ethereum/forks/monad_nine/vm/precompiled_contracts/reserve_balance.py b/src/ethereum/forks/monad_nine/vm/precompiled_contracts/reserve_balance.py new file mode 100644 index 0000000000..240269426d --- /dev/null +++ b/src/ethereum/forks/monad_nine/vm/precompiled_contracts/reserve_balance.py @@ -0,0 +1,105 @@ +""" +Ethereum Virtual Machine (EVM) RESERVE BALANCE PRECOMPILED CONTRACT. + +.. contents:: Table of Contents + :backlinks: none + :local: + +Introduction +------------ + +Implementation of the RESERVE BALANCE precompiled contract for MIP-4. +""" + +from ethereum_types.numeric import U256, Uint + +from ...vm import Evm +from ...vm.exceptions import InvalidParameter, RevertInMonadPrecompile +from ...vm.gas import GAS_WARM_ACCESS, charge_gas + +# Function selector for dippedIntoReserve() +# keccak256("dippedIntoReserve()")[:4].hex() == "3a61584e" +DIPPED_INTO_RESERVE_SELECTOR = bytes.fromhex("3a61584e") + +GAS_ERROR_THRESHOLD = Uint(40000) + + +def _is_call(evm: Evm) -> bool: + # STATICCALL: is_static is True + # DELEGATECALL: should_transfer_value is False + # CALLCODE: code_address != current_target + if evm.message.is_static: + return False + if not evm.message.should_transfer_value: + return False + if evm.message.code_address != evm.message.current_target: + return False + return True + + +def reserve_balance(evm: Evm) -> None: + """ + Return whether execution is in reserve balance violation. + + The precompile must be invoked via CALL. Invocations via STATICCALL, + DELEGATECALL, or CALLCODE must revert. + + The method is not payable and must revert with the error message + "value is nonzero" when called with a nonzero value. + + Calldata must be exactly the 4-byte function selector (0x3a61584e). + If the selector does not match or calldata is shorter than 4 bytes, + the precompile reverts. The error message "method not supported" is + only included when the gas provided to the call frame is at least + GAS_ERROR_THRESHOLD; otherwise the revert carries no return data. + If extra calldata is appended beyond the selector, the precompile + reverts with "input is invalid". + + Reverts consume all gas provided to the call frame. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + from ..interpreter import is_reserve_balance_violated + + data = evm.message.data + + # Must be invoked via CALL only (not STATICCALL, DELEGATECALL, CALLCODE) + if not _is_call(evm): + raise InvalidParameter + + # GAS + charge_gas(evm, GAS_WARM_ACCESS) + + if len(data) < 4: + if evm.message.gas >= GAS_ERROR_THRESHOLD: + evm.output = b"method not supported" + else: + evm.output = b"" + raise RevertInMonadPrecompile + + if data[:4] != DIPPED_INTO_RESERVE_SELECTOR: + if evm.message.gas >= GAS_ERROR_THRESHOLD: + evm.output = b"method not supported" + else: + evm.output = b"" + raise RevertInMonadPrecompile + + if evm.message.value != 0: + evm.output = b"value is nonzero" + raise RevertInMonadPrecompile + + if len(data) > 4: + evm.output = b"input is invalid" + raise RevertInMonadPrecompile + + # OPERATION + violation = is_reserve_balance_violated( + evm.message.block_env.state, + evm.message.tx_env, + ) + # Return bool encoded as uint256 (32 bytes) + evm.output = U256(1 if violation else 0).to_be_bytes32() diff --git a/tests/monad_nine/__init__.py b/tests/monad_nine/__init__.py new file mode 100644 index 0000000000..fb2be240eb --- /dev/null +++ b/tests/monad_nine/__init__.py @@ -0,0 +1 @@ +"""MONAD_NINE fork tests.""" diff --git a/tests/monad_nine/mip4_checkreservebalance/__init__.py b/tests/monad_nine/mip4_checkreservebalance/__init__.py new file mode 100644 index 0000000000..a1476504e9 --- /dev/null +++ b/tests/monad_nine/mip4_checkreservebalance/__init__.py @@ -0,0 +1 @@ +"""Tests for MIP-4 check reserve balance introspection.""" diff --git a/tests/monad_nine/mip4_checkreservebalance/conftest.py b/tests/monad_nine/mip4_checkreservebalance/conftest.py new file mode 100644 index 0000000000..43e30434c0 --- /dev/null +++ b/tests/monad_nine/mip4_checkreservebalance/conftest.py @@ -0,0 +1,45 @@ +"""Pytest configuration for MIP-4 reserve balance introspection tests.""" + +import pytest +from execution_testing import Address, Alloc, Bytecode, Op + +from .helpers import RefillCall, RefillFactory +from .spec import Spec + + +@pytest.fixture +def refill_factory(pre: Alloc) -> RefillFactory: + """ + Fixture that provides a factory for creating refill contracts. + + Returns a function that, when called, deploys a new refill contract and + returns a callable to generate bytecode that calls the refill contract. + """ + + def factory_function() -> RefillCall: + """ + Deploy a refill contract and return call helper. + + The refill contract uses SELFDESTRUCT to transfer RESERVE_BALANCE to + the address provided in calldata. SELFDESTRUCT sends ETH without + triggering target's code, avoiding recursion with delegated EOAs. + """ + code = bytes(Op.SELFDESTRUCT(Op.CALLDATALOAD(0))) + refill_address = pre.deploy_contract( + code=code, balance=Spec.RESERVE_BALANCE + ) + + def make_refill_call(target: Address | Bytecode) -> Bytecode: + """ + Generate bytecode to call the refill contract with target address. + """ + return Op.MSTORE(0, target) + Op.CALL( + gas=Op.GAS, + address=refill_address, + args_offset=0, + args_size=32, + ) + + return make_refill_call + + return factory_function diff --git a/tests/monad_nine/mip4_checkreservebalance/helpers.py b/tests/monad_nine/mip4_checkreservebalance/helpers.py new file mode 100644 index 0000000000..cb08d10d66 --- /dev/null +++ b/tests/monad_nine/mip4_checkreservebalance/helpers.py @@ -0,0 +1,54 @@ +"""Helper functions and fixtures for MIP-4 reserve balance precompile tests.""" + +from typing import Callable + +from execution_testing import Address, Bytecode, Op +from execution_testing.forks.helpers import Fork + +from .spec import Spec + +RefillCall = Callable[[Address | Bytecode], Bytecode] +RefillFactory = Callable[[], RefillCall] + +# Bytecode to store the selector at mem[28:32] +SELECTOR_SETUP = Op.MSTORE(0, Spec.DIPPED_INTO_RESERVE_SELECTOR) + + +def call_dipped_into_reserve() -> Bytecode: + """ + Generate bytecode to call dippedIntoReserve() precompile. + + Returns bytecode that leaves the result (0 or 1) on the stack. + The precompile must be invoked via CALL (not STATICCALL/DELEGATECALL/ + CALLCODE). + """ + return ( + SELECTOR_SETUP + + Op.CALL( + gas=Op.GAS, + address=Spec.RESERVE_BALANCE_PRECOMPILE, + args_offset=28, + args_size=4, + ret_offset=0, + ret_size=32, + ) + + Op.POP + + Op.MLOAD(0) + ) + + +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 + selfdestruct_cost = gas_costs.G_SELF_DESTRUCT + return ( + constant + + 5 * sstore_cost + + deploy_cost + + 6 * access_cost + + 3 * selfdestruct_cost + ) diff --git a/tests/monad_nine/mip4_checkreservebalance/spec.py b/tests/monad_nine/mip4_checkreservebalance/spec.py new file mode 100644 index 0000000000..5704b70d79 --- /dev/null +++ b/tests/monad_nine/mip4_checkreservebalance/spec.py @@ -0,0 +1,38 @@ +"""Defines MIP-4 reserve balance precompile specification constants.""" + +from dataclasses import dataclass + +from execution_testing import Address + + +@dataclass(frozen=True) +class ReferenceSpec: + """Defines the reference spec version and git path.""" + + git_path: str + version: str + + +ref_spec_mip4 = ReferenceSpec("MIPS/MIP-4.md", "main") + + +@dataclass(frozen=True) +class Spec: + """Parameters from the MIP-4 specification.""" + + RESERVE_BALANCE = 10 * 10**18 + + RESERVE_BALANCE_PRECOMPILE = Address(0x1001) + + # Aligns with G_WARM_ACCOUNT_ACCESS at time of MIP-4. + GAS_COST = 100 + # If gas is at or above this, ERROR_METHOD_NOT_SUPPORTED may + # be returned on revert. + GAS_ERROR_THRESHOLD = 40000 + + # keccak256("dippedIntoReserve()")[:4].hex() == "3a61584e" + DIPPED_INTO_RESERVE_SELECTOR = bytes.fromhex("3A61584E") + + ERROR_METHOD_NOT_SUPPORTED = "method not supported" + ERROR_INPUT_INVALID = "input is invalid" + ERROR_VALUE_NONZERO = "value is nonzero" diff --git a/tests/monad_nine/mip4_checkreservebalance/test_fork_transition.py b/tests/monad_nine/mip4_checkreservebalance/test_fork_transition.py new file mode 100644 index 0000000000..a48df99dcd --- /dev/null +++ b/tests/monad_nine/mip4_checkreservebalance/test_fork_transition.py @@ -0,0 +1,119 @@ +""" +Tests for reserve balance precompile fork transition behavior. + +Tests verify that the reserve balance precompile is not available before the +fork and becomes available at and after the fork transition. +""" + +import pytest +from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + Op, + Transaction, +) +from execution_testing.forks.helpers import Fork + +from .helpers import SELECTOR_SETUP, generous_gas +from .spec import Spec, ref_spec_mip4 + +REFERENCE_SPEC_GIT_PATH = ref_spec_mip4.git_path +REFERENCE_SPEC_VERSION = ref_spec_mip4.version + + +@pytest.mark.valid_at_transition_to("MONAD_NEXT", subsequent_forks=True) +def test_fork_transition( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Test reserve balance precompile availability at fork transition. + + Before the fork, the precompile doesn't exist, so CALL returns + empty output (RETURNDATASIZE == 0). After the fork, the precompile + returns a 32-byte result (RETURNDATASIZE == 32). + """ + sender = pre.fund_eoa() + + callee_code = ( + SELECTOR_SETUP + + Op.CALL( + gas=Op.GAS, + address=Spec.RESERVE_BALANCE_PRECOMPILE, + args_offset=28, + args_size=4, + ret_offset=0, + ret_size=32, + ) + + Op.POP + + Op.SSTORE(Op.TIMESTAMP, Op.EQ(Op.RETURNDATASIZE, 32)) + + Op.STOP + ) + callee_address = pre.deploy_contract( + code=callee_code, + storage={14_999: "0xdeadbeef"}, + ) + caller_address = pre.deploy_contract( + code=Op.SSTORE( + Op.TIMESTAMP, Op.CALL(gas=0xFFFF, address=callee_address) + ), + storage={14_999: "0xdeadbeef"}, + ) + blocks = [ + Block( + timestamp=14_999, + txs=[ + Transaction( + to=caller_address, + sender=sender, + nonce=0, + gas_limit=generous_gas(fork), + ) + ], + ), + Block( + timestamp=15_000, + txs=[ + Transaction( + to=caller_address, + sender=sender, + nonce=1, + gas_limit=generous_gas(fork), + ) + ], + ), + Block( + timestamp=15_001, + txs=[ + Transaction( + to=caller_address, + sender=sender, + nonce=2, + gas_limit=generous_gas(fork), + ) + ], + ), + ] + blockchain_test( + pre=pre, + blocks=blocks, + post={ + caller_address: Account( + storage={ + 14_999: 1, # Call succeeds (precompile just returns empty) + 15_000: 1, # Call succeeds on fork transition block + 15_001: 1, # Call continues to succeed after transition + } + ), + callee_address: Account( + storage={ + 14_999: 0, # Precompile not available, RETURNDATASIZE==0 + 15_000: 1, # Precompile available, RETURNDATASIZE==32 + 15_001: 1, # Precompile continues to work + } + ), + }, + ) diff --git a/tests/monad_nine/mip4_checkreservebalance/test_multi_block.py b/tests/monad_nine/mip4_checkreservebalance/test_multi_block.py new file mode 100644 index 0000000000..2a5767a833 --- /dev/null +++ b/tests/monad_nine/mip4_checkreservebalance/test_multi_block.py @@ -0,0 +1,185 @@ +""" +Tests dippedIntoReserve() observes the reserve balance rules spanning multiple +blocks. +""" + +from typing import Tuple + +import pytest +from execution_testing import ( + Account, + Address, + Alloc, + AuthorizationTuple, + Block, + BlockchainTestFiller, + Op, + Transaction, +) +from execution_testing.forks.helpers import Fork + +from .helpers import RefillFactory, call_dipped_into_reserve, generous_gas +from .spec import Spec, ref_spec_mip4 + +REFERENCE_SPEC_GIT_PATH = ref_spec_mip4.git_path +REFERENCE_SPEC_VERSION = ref_spec_mip4.version + +slot_code_worked = 0x1 +value_code_worked = 0x1234 +slot_violation_result = 0x2 + +pytestmark = [ + pytest.mark.valid_from("MONAD_NEXT"), + pytest.mark.pre_alloc_group( + "mip4_reserve_balance_introspection_tests", + reason="Tests reserve balance introspection", + ), +] + +GAS_PRICE = 10 + + +@pytest.mark.parametrize( + ["value", "balance", "violation"], + [ + pytest.param(0, Spec.RESERVE_BALANCE, False, id="zero_value"), + pytest.param(1, Spec.RESERVE_BALANCE, True, id="non_zero_value"), + pytest.param( + 1, Spec.RESERVE_BALANCE + 1, False, id="non_zero_value_good" + ), + ], +) +@pytest.mark.parametrize("pre_delegated", [True, False]) +@pytest.mark.parametrize( + "delegate_pos", [None, (0, 0), (0, 1), (1, 0), (2, 1), (3, 0), (3, 1)] +) +@pytest.mark.parametrize( + "undelegate_pos", [None, (0, 0), (0, 1), (1, 0), (2, 1), (3, 0), (3, 1)] +) +@pytest.mark.parametrize( + "send_pos", [None, (0, 0), (0, 1), (1, 0), (2, 1), (3, 0), (3, 1)] +) +def test_exception_rule( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + refill_factory: RefillFactory, + value: int, + balance: int, + violation: bool, + pre_delegated: bool, + delegate_pos: Tuple[int, int] | None, + undelegate_pos: Tuple[int, int] | None, + send_pos: Tuple[int, int] | None, + fork: Fork, +) -> None: + """ + Test reserve balance violations for an EOA sending txs with vaious values, + where the exception rules are enforced based on txs in various block + positions. + """ + refill_call = refill_factory() + # gas spend by transactions send in setup blocks + prepare_tx_gas = ( + fork.gas_costs().G_TRANSACTION + fork.gas_costs().G_AUTHORIZATION * 2 + ) + # if any of the transactions in setup blocks are sent by main sender we + # need to credit them extra + prepare_tx_fee = GAS_PRICE * prepare_tx_gas if send_pos else 0 + balance += prepare_tx_fee + + target_address = Address(0x1111) + if pre_delegated: + test_sender = pre.fund_eoa(balance, delegation=target_address) + else: + test_sender = pre.fund_eoa(balance) + + contract = ( + Op.SSTORE(slot_violation_result, call_dipped_into_reserve()) + + refill_call(Op.ORIGIN) + + Op.SSTORE(slot_code_worked, value_code_worked) + ) + contract_address = pre.deploy_contract(contract) + + nblocks = 4 + blocks = [] + test_sender_nonce = int(test_sender.nonce) + latest_delegated_block = nblocks if pre_delegated else -1 + for nblock in range(nblocks): + txs = [] + for ntx in range(2): + authorization_list = [] + pos = (nblock, ntx) + + if send_pos == pos: + nonce = test_sender_nonce + sender = test_sender + test_sender_nonce += 1 + else: + nonce = 0 + sender = pre.fund_eoa() + + if delegate_pos == pos: + authorization_list += [ + AuthorizationTuple( + address=target_address, + nonce=test_sender_nonce, + signer=test_sender, + ) + ] + test_sender_nonce += 1 + latest_delegated_block = nblocks + if undelegate_pos == pos: + authorization_list += [ + AuthorizationTuple( + address=Address(0), + nonce=test_sender_nonce, + signer=test_sender, + ) + ] + test_sender_nonce += 1 + latest_delegated_block = nblock + prepare_tx = Transaction( + gas_limit=prepare_tx_gas, + max_fee_per_gas=GAS_PRICE, + max_priority_fee_per_gas=GAS_PRICE, + to=Address(0x7873), + nonce=nonce, + sender=sender, + authorization_list=authorization_list or None, + ) + txs.append(prepare_tx) + # If this isn't the last block, we're done. + # If it is, we'll append the test tx below. + if nblock < nblocks - 1: + blocks.append(Block(txs=txs)) + del txs + + test_tx = Transaction( + gas_limit=generous_gas(fork), + max_fee_per_gas=GAS_PRICE, + max_priority_fee_per_gas=GAS_PRICE, + to=contract_address, + nonce=test_sender_nonce, + value=value, + sender=test_sender, + authorization_list=authorization_list or None, + ) + txs.append(test_tx) + blocks.append(Block(txs=txs)) + + any_delegation = latest_delegated_block > 0 + expected_violation = ( + 1 + if (violation and (any_delegation or (send_pos and send_pos[0] > 0))) + else 0 + ) + + storage = { + slot_violation_result: expected_violation, + slot_code_worked: value_code_worked, + } + blockchain_test( + pre=pre, + post={contract_address: Account(storage=storage)}, + blocks=blocks, + ) diff --git a/tests/monad_nine/mip4_checkreservebalance/test_precompile_call.py b/tests/monad_nine/mip4_checkreservebalance/test_precompile_call.py new file mode 100644 index 0000000000..32186fb5af --- /dev/null +++ b/tests/monad_nine/mip4_checkreservebalance/test_precompile_call.py @@ -0,0 +1,699 @@ +""" +Tests for reserve balance precompile call behavior. + +Tests cover: +- Input validation (selector, size) +- Gas consumption and out-of-gas behavior +- Different call opcodes (CALL, DELEGATECALL, CALLCODE, STATICCALL) +- Value transfer with calls +""" + +from enum import Enum, auto, unique + +import pytest +from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + Bytecode, + Op, + StateTestFiller, + Transaction, +) +from execution_testing.forks.helpers import Fork + +from ..mip3_linear_memory.spec import Spec as SpecMIP3 +from .helpers import ( + SELECTOR_SETUP, + generous_gas, +) +from .spec import ( + Spec, + ref_spec_mip4, +) + +REFERENCE_SPEC_GIT_PATH = ref_spec_mip4.git_path +REFERENCE_SPEC_VERSION = ref_spec_mip4.version + +slot_code_worked = 0x1 +value_code_worked = 0x1234 +slot_call_success = 0x2 +slot_return_size = 0x3 +slot_return_value = 0x4 +slot_ret_buffer_value = 0x5 +slot_all_gas_consumed = 0x6 + + +@unique +class CallScenario(Enum): + """Precompile call scenarios for parametrized tests.""" + + SUCCESS = auto() + WRONG_SELECTOR = auto() + SHORT_CALLDATA = auto() + EXTRA_CALLDATA = auto() + NOT_CALL = auto() + NONZERO_VALUE = auto() + LOW_GAS = auto() + + @property + def should_succeed(self) -> bool: + """Return whether this scenario results in a successful call.""" + return self == CallScenario.SUCCESS + + def error_message(self, gas: int = Spec.GAS_COST) -> bytes | None: + """Return raw ASCII error bytes for this scenario, or None.""" + match self: + case ( + CallScenario.SUCCESS + | CallScenario.NOT_CALL + | CallScenario.LOW_GAS + ): + return None + case CallScenario.WRONG_SELECTOR | CallScenario.SHORT_CALLDATA: + if gas >= Spec.GAS_ERROR_THRESHOLD: + return Spec.ERROR_METHOD_NOT_SUPPORTED.encode() + return None + case CallScenario.EXTRA_CALLDATA: + return Spec.ERROR_INPUT_INVALID.encode() + case CallScenario.NONZERO_VALUE: + return Spec.ERROR_VALUE_NONZERO.encode() + + @property + def check_priority(self) -> int: + """Return precompile check priority.""" + if self == CallScenario.SUCCESS: + raise AssertionError("SUCCESS has no check priority") + order = [ + CallScenario.NOT_CALL, + CallScenario.LOW_GAS, + CallScenario.SHORT_CALLDATA, + CallScenario.WRONG_SELECTOR, + CallScenario.NONZERO_VALUE, + CallScenario.EXTRA_CALLDATA, + ] + + return order.index(self) + + +def call_code( + *scenarios: CallScenario, + gas: int | Bytecode = Spec.GAS_COST, + ret_offset: int = 0, + ret_size: int = 0, +) -> Bytecode: + """ + Generate setup + call bytecode for one or more combined scenarios. + + Both the correct and wrong selectors are always written to memory + at separate offsets. The args_offset is chosen based on whether + WRONG_SELECTOR is among the scenarios. + """ + scenario_set = set(scenarios) + + # Memory layout: non-overlapping buffers + # MSTORE(0, selector) -> correct selector at mem[28:32] + # MSTORE(32, 0xDEADBEEF) -> wrong selector at mem[60:64] + correct_sel_args_offset = 28 + wrong_sel_args_offset = 60 + + setup: Bytecode = SELECTOR_SETUP + Op.MSTORE(32, 0xDEADBEEF) + + if CallScenario.WRONG_SELECTOR in scenario_set: + args_offset = wrong_sel_args_offset + else: + args_offset = correct_sel_args_offset + + if CallScenario.SHORT_CALLDATA in scenario_set: + args_size = 3 + elif CallScenario.EXTRA_CALLDATA in scenario_set: + args_size = 5 + else: + args_size = 4 + if ret_size > 0: + assert ret_offset >= args_offset + args_size, ( + "ret buffer must come after args buffer" + ) + + if CallScenario.LOW_GAS in scenario_set: + gas = 99 + + if CallScenario.NONZERO_VALUE in scenario_set: + value = 1 + else: + value = 0 + + if CallScenario.NOT_CALL in scenario_set: + opcode = Op.CALLCODE + else: + opcode = Op.CALL + + return setup + opcode( + gas=gas, + address=Spec.RESERVE_BALANCE_PRECOMPILE, + value=value, + args_offset=args_offset, + args_size=args_size, + ret_offset=ret_offset, + ret_size=ret_size, + ) + + +pytestmark = [ + pytest.mark.valid_from("MONAD_NEXT"), + pytest.mark.pre_alloc_group( + "mip4_checkreservebalance_tests", + reason="Tests reserve balance precompile", + ), +] + + +def _mload_of(msg: bytes) -> int: + """ + Compute the MLOAD uint256 value after a raw error message is written + to a zero-initialised memory slot. CALL copies msg to mem[offset], + MLOAD then reads 32 bytes big-endian with trailing zeros. + """ + return int.from_bytes((msg + b"\x00" * 32)[:32], "big") + + +@pytest.mark.parametrize( + "input_size", + [ + pytest.param(0, id="empty"), + pytest.param(1, id="one_byte"), + pytest.param(2, id="two_bytes"), + pytest.param(3, id="three_bytes"), + pytest.param(4, id="exact"), + pytest.param(5, id="one_extra"), + pytest.param(8, id="four_extra"), + pytest.param(32, id="one_word"), + pytest.param(1028, id="large"), + pytest.param(SpecMIP3.MAX_TX_MEMORY_USAGE, id="max"), + ], +) +@pytest.mark.parametrize("gas", [Spec.GAS_COST, Spec.GAS_ERROR_THRESHOLD]) +def test_input_size( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + input_size: int, + gas: int, + fork: Fork, +) -> None: + """ + Test precompile behavior with various input sizes. + + Calldata must be exactly 4 bytes (the selector). Any other size reverts. + """ + # Store selector at mem[0:4], extra bytes at mem[4:] + contract = ( + Op.MSTORE(0, Spec.DIPPED_INTO_RESERVE_SELECTOR + b"\xff" * 28) + + Op.SSTORE( + slot_call_success, + Op.CALL( + gas=gas, + address=Spec.RESERVE_BALANCE_PRECOMPILE, + args_offset=0, + args_size=input_size, + ret_offset=0, + ret_size=32, + ), + ) + + Op.SSTORE(slot_return_size, Op.RETURNDATASIZE) + + 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(), + ) + + should_succeed = input_size == 4 + + if should_succeed: + expected_return_size = 32 + elif input_size > 4: + expected_return_size = len(Spec.ERROR_INPUT_INVALID) + elif gas >= Spec.GAS_ERROR_THRESHOLD: + expected_return_size = len(Spec.ERROR_METHOD_NOT_SUPPORTED) + else: + expected_return_size = 0 + + blockchain_test( + pre=pre, + post={ + contract_address: Account( + storage={ + slot_call_success: 1 if should_succeed else 0, + slot_return_size: expected_return_size, + slot_code_worked: value_code_worked, + } + ), + }, + blocks=[Block(txs=[tx])], + ) + + +@pytest.mark.parametrize( + "selector", + [ + pytest.param(Spec.DIPPED_INTO_RESERVE_SELECTOR, id="correct_selector"), + pytest.param(0x00000000, id="zero_selector"), + pytest.param(0xFFFFFFFF, id="max_selector"), + pytest.param(0x3A61584F, id="off_by_one"), + ], +) +@pytest.mark.parametrize("gas", [Spec.GAS_COST, Spec.GAS_ERROR_THRESHOLD]) +def test_selector( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + selector: int, + gas: int, + fork: Fork, +) -> None: + """ + Test precompile behavior with various function selectors. + + Correct selector succeeds, wrong selectors cause revert. + """ + contract = ( + Op.PUSH4(selector) + + Op.PUSH1(0) + + Op.MSTORE # Selector at mem[28:32] + + Op.SSTORE( + slot_call_success, + Op.CALL( + gas=gas, + address=Spec.RESERVE_BALANCE_PRECOMPILE, + args_offset=28, + args_size=4, + ret_offset=0, + ret_size=32, + ), + ) + + Op.SSTORE(slot_return_size, Op.RETURNDATASIZE) + + 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(), + ) + + should_succeed = selector == Spec.DIPPED_INTO_RESERVE_SELECTOR + + if should_succeed: + expected_return_size = 32 + elif gas >= Spec.GAS_ERROR_THRESHOLD: + expected_return_size = len(Spec.ERROR_METHOD_NOT_SUPPORTED) + else: + expected_return_size = 0 + + blockchain_test( + pre=pre, + post={ + contract_address: Account( + storage={ + slot_call_success: 1 if should_succeed else 0, + slot_return_size: expected_return_size, + slot_code_worked: value_code_worked, + } + ), + }, + blocks=[Block(txs=[tx])], + ) + + +@pytest.mark.parametrize("enough_gas", [True, False]) +def test_gas( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + enough_gas: bool, +) -> None: + """ + Test that precompile consumes expected gas. + """ + gas_warm_sload = fork.gas_costs().G_WARM_SLOAD + gas = gas_warm_sload if enough_gas else gas_warm_sload - 1 + + contract = ( + SELECTOR_SETUP + + Op.SSTORE( + slot_call_success, + Op.CALL( + gas=gas, + address=Spec.RESERVE_BALANCE_PRECOMPILE, + args_offset=28, + args_size=4, + ret_offset=0, + ret_size=32, + ), + ) + + Op.SSTORE(slot_code_worked, value_code_worked) + ) + contract_address = pre.deploy_contract(contract) + + tx = Transaction( + to=contract_address, + sender=pre.fund_eoa(), + gas_limit=generous_gas(fork), + ) + + state_test( + pre=pre, + post={ + contract_address: Account( + storage={ + slot_call_success: 1 if enough_gas else 0, + slot_code_worked: value_code_worked, + } + ) + }, + tx=tx, + ) + + +@pytest.mark.with_all_call_opcodes() +def test_call_opcodes( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + call_opcode: Op, + fork: Fork, +) -> None: + """ + Test that precompile must be invoked via CALL only. + + STATICCALL, DELEGATECALL, and CALLCODE must revert. + """ + contract = ( + SELECTOR_SETUP + + Op.SSTORE( + slot_call_success, + call_opcode( + gas=10000, + address=Spec.RESERVE_BALANCE_PRECOMPILE, + args_offset=28, + args_size=4, + ret_offset=0, + ret_size=32, + ), + ) + + Op.SSTORE(slot_return_size, Op.RETURNDATASIZE) + + 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(), + ) + + should_succeed = call_opcode == Op.CALL + + blockchain_test( + pre=pre, + post={ + contract_address: Account( + storage={ + slot_call_success: 1 if should_succeed else 0, + slot_return_size: 32 if should_succeed else 0, + slot_code_worked: value_code_worked, + } + ), + }, + blocks=[Block(txs=[tx])], + ) + + +@pytest.mark.parametrize( + "scenario", + [s for s in CallScenario if s != CallScenario.LOW_GAS], +) +# Ensures that the reverts are independent of gas sent. +@pytest.mark.parametrize("gas", [Spec.GAS_COST, 10000, 40000]) +def test_revert_returns( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, + scenario: CallScenario, + gas: int, +) -> None: + """ + Test return data from the precompile on success and on each revert reason. + """ + # Memory layout: non-overlapping buffers; args are at mem[0:64]. + ret_offset = 96 + rdc_offset = 128 + + contract = ( + Op.SSTORE( + slot_call_success, + call_code(scenario, gas=gas, ret_offset=ret_offset, ret_size=32), + ) + + Op.SSTORE(slot_return_size, Op.RETURNDATASIZE) + + Op.SSTORE(slot_ret_buffer_value, Op.MLOAD(ret_offset)) + + Op.RETURNDATACOPY(rdc_offset, 0, Op.RETURNDATASIZE) + + Op.SSTORE(slot_return_value, Op.MLOAD(rdc_offset)) + + Op.SSTORE(slot_code_worked, value_code_worked) + ) + contract_address = pre.deploy_contract(contract, balance=1) + + tx = Transaction( + gas_limit=generous_gas(fork), + to=contract_address, + sender=pre.fund_eoa(), + ) + + err = scenario.error_message(gas) + expected_return_size = ( + 32 if scenario.should_succeed else (len(err) if err else 0) + ) + # On success the precompile returns U256(false)=0; on failure with a + # message, CALL copies err to mem[ret_offset] (zero-initialised). + expected_mload = 0 if err is None else _mload_of(err) + + blockchain_test( + pre=pre, + post={ + contract_address: Account( + storage={ + slot_call_success: 1 if scenario.should_succeed else 0, + slot_return_size: expected_return_size, + slot_ret_buffer_value: expected_mload, + slot_return_value: expected_mload, + slot_code_worked: value_code_worked, + } + ), + }, + blocks=[Block(txs=[tx])], + ) + + +@pytest.mark.parametrize( + "scenario", + [s for s in CallScenario if s != CallScenario.LOW_GAS], +) +def test_revert_consumes_all_gas( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, + scenario: CallScenario, +) -> None: + """ + Test that precompile reverts consume all gas provided to the call frame. + """ + gas_limit = generous_gas(fork) + gas_threshold = gas_limit // 64 + + contract = ( + Op.SSTORE(slot_code_worked, value_code_worked) + + Op.SSTORE(slot_call_success, 1) + + Op.SSTORE(slot_all_gas_consumed, 1) + + Op.SSTORE(slot_call_success, call_code(scenario, gas=Op.GAS)) + + Op.SSTORE(slot_all_gas_consumed, Op.LT(Op.GAS, gas_threshold)) + ) + contract_address = pre.deploy_contract(contract, balance=1) + + tx = Transaction( + gas_limit=gas_limit, + to=contract_address, + sender=pre.fund_eoa(), + ) + + blockchain_test( + pre=pre, + post={ + contract_address: Account( + storage={ + slot_code_worked: value_code_worked, + slot_call_success: 1 if scenario.should_succeed else 0, + slot_all_gas_consumed: 0 if scenario.should_succeed else 1, + } + ), + }, + blocks=[Block(txs=[tx])], + ) + + +@pytest.mark.parametrize("value", [0, 1]) +def test_call_with_value( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + value: int, + fork: Fork, +) -> None: + """ + Test that sending value with CALL causes the call to revert. + """ + contract = ( + SELECTOR_SETUP + + Op.SSTORE( + slot_call_success, + Op.CALL( + gas=10000, + address=Spec.RESERVE_BALANCE_PRECOMPILE, + value=value, + args_offset=28, + args_size=4, + ret_offset=0, + ret_size=32, + ), + ) + + Op.SSTORE(slot_return_size, Op.RETURNDATASIZE) + + Op.SSTORE(slot_return_value, Op.MLOAD(0)) + + Op.SSTORE(slot_code_worked, value_code_worked) + ) + contract_address = pre.deploy_contract(contract, balance=value) + + tx = Transaction( + gas_limit=generous_gas(fork), + to=contract_address, + sender=pre.fund_eoa(), + ) + + should_succeed = value == 0 + + if should_succeed: + # Precompile returns U256(false)=0; ret_offset=0 overwrites the + # selector that SELECTOR_SETUP wrote. + expected_return_value = 0 + else: + # CALL copies error msg (16 bytes) to mem[0:16]; mem[28:32] still + # holds the selector written by SELECTOR_SETUP before the call. + err = Spec.ERROR_VALUE_NONZERO.encode() + sel = Spec.DIPPED_INTO_RESERVE_SELECTOR + expected_return_value = int.from_bytes(err + b"\x00" * 12 + sel, "big") + + storage: dict[int, int] = { + slot_call_success: 1 if should_succeed else 0, + slot_return_size: 32 + if should_succeed + else len(Spec.ERROR_VALUE_NONZERO), + slot_return_value: expected_return_value, + slot_code_worked: value_code_worked, + } + + post = { + contract_address: Account(storage=storage), + } + + blockchain_test( + pre=pre, + post=post, + blocks=[Block(txs=[tx])], + ) + + +# --- Check-order tests and their helpers --- + +_INCOMPATIBLE_SCENARIOS = { + frozenset({CallScenario.SHORT_CALLDATA, CallScenario.EXTRA_CALLDATA}), + frozenset({CallScenario.LOW_GAS, CallScenario.NONZERO_VALUE}), +} + +_CHECK_ORDER_PAIRS = [ + pytest.param( + s1, + s2, + id=f"{s1.name.lower()}__{s2.name.lower()}", + ) + for s1 in CallScenario + for s2 in CallScenario + if s1 != CallScenario.SUCCESS + and s2 != CallScenario.SUCCESS + and s1.check_priority < s2.check_priority + and frozenset({s1, s2}) not in _INCOMPATIBLE_SCENARIOS +] + + +@pytest.mark.parametrize("gas", [Spec.GAS_COST, Spec.GAS_ERROR_THRESHOLD]) +@pytest.mark.parametrize("scenario1,scenario2", _CHECK_ORDER_PAIRS) +def test_check_order( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, + scenario1: CallScenario, + scenario2: CallScenario, + gas: int, +) -> None: + """ + Test precompile check priority with enum-driven failure pairs. + + Each combination triggers exactly two failure causes. The test + derives the expected outcome from the higher-priority failure. + """ + prevailing = min(scenario1, scenario2, key=lambda s: s.check_priority) + expected_msg = prevailing.error_message(gas) or b"" + + # Memory layout: non-overlapping buffers; args are at mem[0:64]. + ret_offset = 96 + rdc_offset = 128 + + contract = ( + Op.SSTORE( + slot_call_success, + call_code( + scenario1, + scenario2, + gas=gas, + ret_offset=ret_offset, + ret_size=32, + ), + ) + + Op.SSTORE(slot_return_size, Op.RETURNDATASIZE) + + Op.RETURNDATACOPY(rdc_offset, 0, Op.RETURNDATASIZE) + + Op.SSTORE(slot_return_value, Op.MLOAD(rdc_offset)) + + Op.SSTORE(slot_code_worked, value_code_worked) + ) + contract_address = pre.deploy_contract(contract, balance=1) + + tx = Transaction( + gas_limit=generous_gas(fork), + to=contract_address, + sender=pre.fund_eoa(), + ) + + expected_return_size = len(expected_msg) + expected_mload = _mload_of(expected_msg) if expected_msg else 0 + + blockchain_test( + pre=pre, + post={ + contract_address: Account( + storage={ + slot_call_success: 0, + slot_return_size: expected_return_size, + slot_return_value: expected_mload, + slot_code_worked: value_code_worked, + } + ), + }, + blocks=[Block(txs=[tx])], + ) diff --git a/tests/monad_nine/mip4_checkreservebalance/test_transfers.py b/tests/monad_nine/mip4_checkreservebalance/test_transfers.py new file mode 100644 index 0000000000..ce2acda807 --- /dev/null +++ b/tests/monad_nine/mip4_checkreservebalance/test_transfers.py @@ -0,0 +1,1541 @@ +""" +Tests for reserve balance precompile with transfers. + +These tests verify that the reserve balance precompile correctly returns +1 when execution is in reserve balance violation and 0 otherwise. +""" + +from enum import Enum, auto, unique +from typing import Callable + +import pytest +from execution_testing import ( + AccessList, + Account, + Address, + Alloc, + AuthorizationTuple, + Block, + BlockchainTestFiller, + Op, + Transaction, +) +from execution_testing.forks.helpers import Fork +from execution_testing.test_types.helpers import compute_create_address +from execution_testing.tools.tools_code.generators import Initcode + +from ...monad_eight.reserve_balance.helpers import ( + Stage1Balance, + StageBalance, +) +from .helpers import RefillFactory, call_dipped_into_reserve, generous_gas +from .spec import Spec, ref_spec_mip4 + +REFERENCE_SPEC_GIT_PATH = ref_spec_mip4.git_path +REFERENCE_SPEC_VERSION = ref_spec_mip4.version + +slot_code_worked = 0x1 +value_code_worked = 0x1234 +slot_violation_result = 0x2 + +slot_violation_after_stage2 = 0x12 +slot_violation_after_stage3 = 0x13 + +pytestmark = [ + pytest.mark.valid_from("MONAD_NEXT"), + pytest.mark.pre_alloc_group( + "mip4_checkreservebalance_tests", + reason="Tests reserve balance precompile", + ), +] + + +GAS_PRICE = 10 +GAS_LIMIT = 500_000 +TX_FEE = GAS_PRICE * GAS_LIMIT + + +def test_smoke_checkreservebalance( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + refill_factory: RefillFactory, + fork: Fork, +) -> None: + """ + Simplest smoke test for reserve balance precompile. + """ + refill_call = refill_factory() + initial_balance = 10 * 10**18 + sender = pre.fund_eoa(initial_balance, delegation=Address(0x0111)) + + contract = ( + Op.SSTORE(slot_violation_result, call_dipped_into_reserve()) + + refill_call(Op.ORIGIN) + + Op.SSTORE(slot_code_worked, value_code_worked) + ) + contract_address = pre.deploy_contract(contract) + + tx_1 = Transaction( + gas_limit=generous_gas(fork), + to=contract_address, + value=1, + sender=sender, + ) + + blockchain_test( + pre=pre, + post={ + contract_address: Account( + storage={ + slot_violation_result: 1, + slot_code_worked: value_code_worked, + } + ), + }, + blocks=[Block(txs=[tx_1])], + ) + + +@unique +class TargetAccountType(Enum): + """Kinds of target accounts for calls.""" + + EMPTY = auto() + EOA = auto() + DELEGATED_EOA = auto() + LEGACY_CONTRACT = auto() + IDENTITY_PRECOMPILE = auto() + + def __str__(self) -> str: + """Return string representation of the enum.""" + return f"{self.name}" + + +@pytest.fixture +def target_address( + pre: Alloc, target_account_type: TargetAccountType +) -> Address: + """Target address of the call depending on required type of account.""" + match target_account_type: + case TargetAccountType.EMPTY: + return pre.fund_eoa(amount=0) + case TargetAccountType.EOA: + return pre.fund_eoa() + case TargetAccountType.DELEGATED_EOA: + return pre.fund_eoa(delegation=Address(0x0111)) + case TargetAccountType.LEGACY_CONTRACT: + return pre.deploy_contract(code=Op.STOP) + case TargetAccountType.IDENTITY_PRECOMPILE: + return Address(0x04) + + +value_balance_violation_param_list = [ + pytest.param( + 0, + Spec.RESERVE_BALANCE, + False, + id="zero_value", + ), + pytest.param( + 0, + Spec.RESERVE_BALANCE - 1, + False, + id="zero_value_init_below_reserve", + ), + pytest.param( + 0, + TX_FEE, + False, + id="zero_value_spend_all_on_gas", + ), + pytest.param( + 1, + Spec.RESERVE_BALANCE - 1, + True, + id="non_zero_value_init_below_reserve", + ), + pytest.param( + 1, + Spec.RESERVE_BALANCE, + True, + id="non_zero_value", + ), + pytest.param( + 1, + Spec.RESERVE_BALANCE + 1, + False, + id="non_zero_value_good", + ), + pytest.param( + Spec.RESERVE_BALANCE - TX_FEE, + Spec.RESERVE_BALANCE, + True, + id="large_value_leave_zero", + ), + pytest.param( + Spec.RESERVE_BALANCE - 1 - TX_FEE, + Spec.RESERVE_BALANCE, + True, + id="large_value_leave_one", + ), + pytest.param( + Spec.RESERVE_BALANCE + 1, + 2 * Spec.RESERVE_BALANCE, + True, + id="large_value_one_below", + ), + pytest.param( + Spec.RESERVE_BALANCE, + 2 * Spec.RESERVE_BALANCE, + False, + id="large_value_good", + ), + pytest.param( + Spec.RESERVE_BALANCE - TX_FEE, + Spec.RESERVE_BALANCE, + True, + id="value_equal_to_balance_minus_gas", + ), + pytest.param( + Spec.RESERVE_BALANCE, + Spec.RESERVE_BALANCE + TX_FEE, + True, + id="balance_equal_to_value_plus_gas", + ), + pytest.param( + 10 * Spec.RESERVE_BALANCE, + 100 * Spec.RESERVE_BALANCE + TX_FEE, + False, + id="well_above_reserve", + ), + pytest.param( + 10 * Spec.RESERVE_BALANCE, + 2**256 - 1, + False, + id="well_above_reserve_maxed_balance", + ), + pytest.param( + 0, + 2**256 - 1, + False, + id="zero_maxed_balance", + ), + pytest.param( + 1, + 2**256 - 1, + False, + id="one_maxed_balance", + ), + pytest.param( + 2**256 - 1 - TX_FEE, + 2**256 - 1, + True, + id="maxed_out", + ), + pytest.param( + 2**256 - 1 - Spec.RESERVE_BALANCE, + 2**256 - 1, + False, + id="maxed_out_good", + ), + pytest.param( + 2**256 - 1 - Spec.RESERVE_BALANCE + 1, + 2**256 - 1, + True, + id="maxed_out_minimal_violation", + ), + pytest.param( + 2 * Spec.RESERVE_BALANCE, + 3 * Spec.RESERVE_BALANCE - 1, + True, + id="more_than_half_balance", + ), + pytest.param( + 2 * Spec.RESERVE_BALANCE, + 3 * Spec.RESERVE_BALANCE, + False, + id="more_than_half_balance_good", + ), +] + + +@pytest.mark.parametrize( + ["value", "balance", "violation"], + value_balance_violation_param_list, +) +@pytest.mark.parametrize("pre_delegated", [True, False]) +@pytest.mark.parametrize("delegate", [True, False]) +@pytest.mark.parametrize("undelegate", [True, False]) +def test_delegated_eoa_send_value( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + refill_factory: RefillFactory, + value: int, + balance: int, + violation: bool, + pre_delegated: bool, + delegate: bool, + undelegate: bool, +) -> None: + """ + Test dippedIntoReserve() returns correct value for an EOA sending txs. + """ + refill_call = refill_factory() + target_address = Address(0x1111) + if pre_delegated: + sender = pre.fund_eoa(balance, delegation=target_address) + else: + sender = pre.fund_eoa(balance) + + authorization_list = [] + + if delegate: + authorization_list += [ + AuthorizationTuple( + address=target_address, + nonce=sender.nonce + 1, + signer=sender, + ) + ] + if undelegate: + authorization_list += [ + AuthorizationTuple( + address=Address(0), + nonce=(sender.nonce + 2 if delegate else sender.nonce + 1), + signer=sender, + ) + ] + + contract = Op.SSTORE(slot_code_worked, value_code_worked) + Op.SSTORE( + slot_violation_result, call_dipped_into_reserve() + ) + + # avoid overflow for cases where refill goes above max + if balance - value + Spec.RESERVE_BALANCE < 2**256: + contract += refill_call(Op.ORIGIN) + contract_address = pre.deploy_contract(contract) + + tx_1 = Transaction( + gas_limit=GAS_LIMIT, + max_fee_per_gas=GAS_PRICE, + max_priority_fee_per_gas=GAS_PRICE, + to=contract_address, + value=value, + sender=sender, + authorization_list=authorization_list or None, + ) + + any_delegation = pre_delegated or delegate or undelegate + expected_violation = 1 if (violation and any_delegation) else 0 + + storage = { + slot_violation_result: expected_violation, + slot_code_worked: value_code_worked, + } + + blockchain_test( + pre=pre, + post={ + contract_address: Account(storage=storage), + }, + blocks=[Block(txs=[tx_1])], + ) + + +@pytest.mark.parametrize( + ["value", "balance", "violation"], + value_balance_violation_param_list, +) +@pytest.mark.parametrize("pre_delegated", [True, False]) +def test_sc_wallet_send_value( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + refill_factory: RefillFactory, + value: int, + balance: int, + violation: bool, + pre_delegated: bool, + fork: Fork, +) -> None: + """ + Test dippedIntoReserve() for an EOA sending txs with various values + using a CALL opcode in a smart contract wallet. + """ + refill_call = refill_factory() + contract = Op.SSTORE(slot_code_worked, value_code_worked) + Op.SSTORE( + slot_violation_result, call_dipped_into_reserve() + ) + contract_address = pre.deploy_contract(contract) + + wallet_code = Op.CALL(address=contract_address, value=value) + + # avoid overflow for cases where refill goes above max + if balance - value + Spec.RESERVE_BALANCE < 2**256: + wallet_code += refill_call(Op.ADDRESS) + wallet_address = pre.deploy_contract(code=wallet_code) + + if pre_delegated: + sender = pre.fund_eoa(balance, delegation=wallet_address) + authorization_list = [] + else: + sender = pre.fund_eoa(balance) + authorization_list = [ + AuthorizationTuple(address=wallet_address, nonce=0, signer=sender) + ] + + tx_1 = Transaction( + gas_limit=generous_gas(fork), + to=sender, + sender=pre.fund_eoa(), + authorization_list=authorization_list or None, + ) + + expected_violation = 1 if violation else 0 + storage = { + slot_violation_result: expected_violation, + slot_code_worked: value_code_worked, + } + + blockchain_test( + pre=pre, + post={contract_address: Account(storage=storage, balance=value)}, + blocks=[Block(txs=[tx_1])], + ) + + +@pytest.mark.parametrize( + ["value", "balance", "violation"], + [ + pytest.param(0, Spec.RESERVE_BALANCE, False, id="zero_value"), + pytest.param(1, Spec.RESERVE_BALANCE, True, id="non_zero_value"), + pytest.param( + 1, Spec.RESERVE_BALANCE + 1, False, id="non_zero_value_good" + ), + ], +) +@pytest.mark.parametrize("pre_delegated", [True, False]) +@pytest.mark.parametrize("delegate", [True, False]) +@pytest.mark.parametrize("undelegate", [True, False]) +def test_sc_wallet_selfdestruct( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + refill_factory: RefillFactory, + value: int, + balance: int, + violation: bool, + pre_delegated: bool, + delegate: bool, + undelegate: bool, + fork: Fork, +) -> None: + """ + Test dippedIntoReserve() for a delegated EOA whose wallet code + SELFDESTRUCTs on behalf of the EOA. + """ + refill_call = refill_factory() + wallet_address = pre.deploy_contract(code=Op.SELFDESTRUCT(Op.ADDRESS)) + + if pre_delegated: + sender = pre.fund_eoa(balance, delegation=wallet_address) + else: + sender = pre.fund_eoa(balance) + + authorization_list = [] + if delegate: + authorization_list.append( + AuthorizationTuple( + address=wallet_address, + nonce=sender.nonce + 1, + signer=sender, + ) + ) + if undelegate: + authorization_list.append( + AuthorizationTuple( + address=Address(0), + nonce=sender.nonce + (2 if delegate else 1), + signer=sender, + ) + ) + + contract_address = pre.deploy_contract( + code=Op.SSTORE(slot_code_worked, value_code_worked) + + Op.CALL(address=sender) + + Op.SSTORE(slot_violation_result, call_dipped_into_reserve()) + + refill_call(Op.ORIGIN) + ) + + tx_1 = Transaction( + gas_limit=generous_gas(fork), + to=contract_address, + value=value, + sender=sender, + authorization_list=authorization_list or None, + ) + + any_delegation = pre_delegated or delegate or undelegate + expected_violation = 1 if (violation and any_delegation) else 0 + + storage = { + slot_violation_result: expected_violation, + slot_code_worked: value_code_worked, + } + + blockchain_test( + pre=pre, + post={contract_address: Account(storage=storage, balance=value)}, + blocks=[Block(txs=[tx_1])], + ) + + +@pytest.mark.parametrize( + ["value1", "balance1", "violation1"], + [ + pytest.param(0, Spec.RESERVE_BALANCE, False, id="zero_value1"), + pytest.param(1, Spec.RESERVE_BALANCE, True, id="non_zero_value1"), + pytest.param( + 1, Spec.RESERVE_BALANCE + 1, False, id="non_zero_value_good1" + ), + ], +) +@pytest.mark.parametrize( + ["value2", "balance2", "violation2"], + [ + pytest.param(0, Spec.RESERVE_BALANCE, False, id="zero_value2"), + pytest.param(1, Spec.RESERVE_BALANCE, True, id="non_zero_value2"), + pytest.param( + 1, Spec.RESERVE_BALANCE + 1, False, id="non_zero_value_good2" + ), + ], +) +@pytest.mark.parametrize("pre_delegated", [True, False]) +@pytest.mark.parametrize("revert_subcall1", [True, False]) +@pytest.mark.parametrize("revert_subcall2", [True, False]) +def test_multiple_violating_senders( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + refill_factory: RefillFactory, + value1: int, + balance1: int, + violation1: bool, + value2: int, + balance2: int, + violation2: bool, + pre_delegated: bool, + revert_subcall1: bool, + revert_subcall2: bool, + fork: Fork, +) -> None: + """ + Test dippedIntoReserve() when two delegated EOAs are spending in the tx. + + Also check dippedIntoReserve() called by different accounts. + + When a subcall reverts, its reserve balance violation is rolled back, + allowing following call frames to proceed independently. This covers + the essential usecase of the dippedIntoReserve() function. + """ + contract = Op.SSTORE(slot_code_worked, value_code_worked) + Op.SSTORE( + Op.CALLER, call_dipped_into_reserve() + ) + contract_address = pre.deploy_contract(contract) + + # Build wallet code for each sender: spend value, check violation, + # then either revert (rolling back the violation) or refill. + wallet_code1 = Op.CALL(address=contract_address, value=value1) + Op.SSTORE( + slot_violation_result, call_dipped_into_reserve() + ) + if revert_subcall1: + wallet_code1 += Op.REVERT(0, 0) + else: + wallet_code1 += refill_factory()(Op.ADDRESS) + + wallet_code2 = Op.CALL(address=contract_address, value=value2) + Op.SSTORE( + slot_violation_result, call_dipped_into_reserve() + ) + if revert_subcall2: + wallet_code2 += Op.REVERT(0, 0) + else: + wallet_code2 += refill_factory()(Op.ADDRESS) + + wallet_address1 = pre.deploy_contract(code=wallet_code1) + wallet_address2 = pre.deploy_contract(code=wallet_code2) + + if pre_delegated: + sender1 = pre.fund_eoa(balance1, delegation=wallet_address1) + sender2 = pre.fund_eoa(balance2, delegation=wallet_address2) + authorization_list = [] + else: + sender1 = pre.fund_eoa(balance1) + sender2 = pre.fund_eoa(balance2) + authorization_list = [ + AuthorizationTuple( + address=wallet_address1, + nonce=0, + signer=sender1, + ), + AuthorizationTuple( + address=wallet_address2, + nonce=0, + signer=sender2, + ), + ] + + dispatcher = pre.deploy_contract( + Op.CALL(address=sender1) + Op.CALL(address=sender2) + ) + + tx_1 = Transaction( + gas_limit=generous_gas(fork), + to=dispatcher, + sender=pre.fund_eoa(), + authorization_list=authorization_list or None, + ) + + expected_violation1 = 1 if violation1 else 0 + expected_violation2 = 1 if violation2 else 0 + + contract_storage: dict = {} + if not revert_subcall1 or not revert_subcall2: + contract_storage[slot_code_worked] = value_code_worked + if not revert_subcall1: + contract_storage[sender1] = expected_violation1 + if not revert_subcall2: + contract_storage[sender2] = expected_violation2 + + contract_balance = (value1 if not revert_subcall1 else 0) + ( + value2 if not revert_subcall2 else 0 + ) + + blockchain_test( + pre=pre, + post={ + sender1: Account( + storage={slot_violation_result: expected_violation1} + if not revert_subcall1 + else {} + ), + sender2: Account( + storage={slot_violation_result: expected_violation2} + if not revert_subcall2 + else {} + ), + contract_address: Account( + storage=contract_storage, + balance=contract_balance, + ), + }, + blocks=[Block(txs=[tx_1])], + ) + + +@pytest.mark.parametrize( + ["value", "balance", "violation"], + [ + pytest.param(0, Spec.RESERVE_BALANCE, False, id="zero_value"), + pytest.param(1, Spec.RESERVE_BALANCE, True, id="non_zero_value"), + pytest.param( + 1, Spec.RESERVE_BALANCE + 1, False, id="non_zero_value_good" + ), + ], +) +@pytest.mark.parametrize("pre_delegated", [True, False]) +@pytest.mark.parametrize( + "target_account_type", + [ + TargetAccountType.EMPTY, + TargetAccountType.EOA, + TargetAccountType.DELEGATED_EOA, + TargetAccountType.LEGACY_CONTRACT, + TargetAccountType.IDENTITY_PRECOMPILE, + ], +) +def test_delegated_various_targets( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + refill_factory: RefillFactory, + value: int, + balance: int, + violation: bool, + pre_delegated: bool, + target_address: Address, + fork: Fork, +) -> None: + """ + Test dippedIntoReserve() for an EOA delegated to various kinds of targets. + """ + refill_call = refill_factory() + if pre_delegated: + sender = pre.fund_eoa(balance, delegation=target_address) + else: + sender = pre.fund_eoa(balance) + + contract = ( + Op.SSTORE(slot_violation_result, call_dipped_into_reserve()) + + refill_call(Op.ORIGIN) + + Op.SSTORE(slot_code_worked, value_code_worked) + + Op.STOP + ) + contract_address = pre.deploy_contract(contract) + + tx_1 = Transaction( + gas_limit=generous_gas(fork), + to=contract_address, + value=value, + sender=sender, + ) + + expected_violation = 1 if (violation and pre_delegated) else 0 + + storage = { + slot_violation_result: expected_violation, + slot_code_worked: value_code_worked, + } + + blockchain_test( + pre=pre, + post={contract_address: Account(storage=storage)}, + blocks=[Block(txs=[tx_1])], + ) + + +@pytest.mark.parametrize( + ["value", "balance", "violation"], + [ + pytest.param(0, Spec.RESERVE_BALANCE, False, id="zero_value"), + pytest.param(1, Spec.RESERVE_BALANCE, True, id="non_zero_value"), + pytest.param( + 1, Spec.RESERVE_BALANCE + 1, False, id="non_zero_value_good" + ), + pytest.param( + 2 * Spec.RESERVE_BALANCE, + 3 * Spec.RESERVE_BALANCE - 1, + True, + id="more_than_half_balance", + ), + pytest.param( + 2 * Spec.RESERVE_BALANCE, + 3 * Spec.RESERVE_BALANCE, + False, + id="more_than_half_balance_good", + ), + ], +) +@pytest.mark.parametrize("pre_delegated", [True, False]) +@pytest.mark.parametrize("pre_funded", [True, False, "half"]) +def test_credit_same_tx( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + refill_factory: RefillFactory, + value: int, + balance: int, + violation: bool, + pre_delegated: bool, + pre_funded: bool | str, + fork: Fork, +) -> None: + """ + Test dippedIntoReserve() for an EOA credited during the otherwise + violating tx. + """ + refill_call = refill_factory() + contract = Op.SSTORE(slot_code_worked, value_code_worked) + Op.SSTORE( + slot_violation_result, call_dipped_into_reserve() + ) + contract_address = pre.deploy_contract(contract) + + wallet_address = pre.deploy_contract( + code=Op.CALL(address=contract_address, value=value) + + refill_call(Op.ADDRESS) + ) + pre_funded_balance = ( + balance // 2 if pre_funded == "half" else balance if pre_funded else 0 + ) + if pre_delegated: + sender = pre.fund_eoa(pre_funded_balance, delegation=wallet_address) + authorization_list = [] + else: + sender = pre.fund_eoa(pre_funded_balance) + authorization_list = [ + AuthorizationTuple( + address=wallet_address, + nonce=0, + signer=sender, + ) + ] + same_tx_funded_balance = balance - pre_funded_balance + tx_1 = Transaction( + gas_limit=generous_gas(fork), + to=sender, + sender=pre.fund_eoa(), + value=same_tx_funded_balance, + authorization_list=authorization_list or None, + ) + # If tx_1 net transfer is incoming there is no revert even if balance ends + # below reserve + expected_violation = ( + 1 if violation and value > same_tx_funded_balance else 0 + ) + print( + value > same_tx_funded_balance, + value, + same_tx_funded_balance, + balance - value < Spec.RESERVE_BALANCE, + ) + storage = { + slot_violation_result: expected_violation, + slot_code_worked: value_code_worked, + } + + blockchain_test( + pre=pre, + post={contract_address: Account(storage=storage, balance=value)}, + blocks=[Block(txs=[tx_1])], + ) + + +@pytest.mark.parametrize( + ["value", "balance", "violation"], + [ + pytest.param(0, Spec.RESERVE_BALANCE, False, id="zero_value"), + pytest.param(1, Spec.RESERVE_BALANCE, True, id="non_zero_value"), + pytest.param( + 1, Spec.RESERVE_BALANCE + 1, False, id="non_zero_value_good" + ), + ], +) +@pytest.mark.parametrize("pre_delegated", [True, False]) +@pytest.mark.parametrize("credit_value", [None, 0, 1]) +def test_credit_in_same_tx_same_call_frame( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + refill_factory: RefillFactory, + value: int, + balance: int, + violation: bool, + pre_delegated: bool, + credit_value: int | None, + fork: Fork, +) -> None: + """ + Test dippedIntoReserve() for an EOA credited during the otherwise + violating tx, within the frame that debited the EOA. + + dippedIntoReserve() is called AFTER creditor (if any) so it reflects + the state after credit has been applied. + """ + refill_call = refill_factory() + if pre_delegated: + sender = pre.fund_eoa(balance, delegation=Address(0x1111)) + else: + sender = pre.fund_eoa(balance) + + contract = Op.SSTORE(slot_code_worked, value_code_worked) + if credit_value is not None: + creditor = pre.deploy_contract( + Op.CALL(address=sender, value=credit_value), + balance=credit_value, + ) + + contract += Op.CALL(address=creditor) + contract += Op.SSTORE( + slot_violation_result, call_dipped_into_reserve() + ) + refill_call(Op.ORIGIN) + contract_address = pre.deploy_contract(contract) + + tx_1 = Transaction( + gas_limit=generous_gas(fork), + to=contract_address, + value=value, + sender=sender, + ) + + expected_violation = ( + 1 if (violation and pre_delegated and not credit_value) else 0 + ) + + storage = { + slot_violation_result: expected_violation, + slot_code_worked: value_code_worked, + } + + blockchain_test( + pre=pre, + post={contract_address: Account(storage=storage)}, + blocks=[Block(txs=[tx_1])], + ) + + +@pytest.mark.parametrize( + ["value", "balance", "violation"], + [ + pytest.param(0, Spec.RESERVE_BALANCE, False, id="zero_value"), + pytest.param(1, Spec.RESERVE_BALANCE, True, id="non_zero_value"), + pytest.param( + 1, Spec.RESERVE_BALANCE + 1, False, id="non_zero_value_good" + ), + ], +) +@pytest.mark.parametrize("credit_value", [None, 0, 1]) +def test_credit_after_call_frame( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + refill_factory: RefillFactory, + value: int, + balance: int, + violation: bool, + credit_value: int | None, + fork: Fork, +) -> None: + """ + Test dippedIntoReserve() for an EOA credited after the spending + (violating) call frame exits. + """ + refill_call = refill_factory() + contract = Op.SSTORE(slot_code_worked, value_code_worked) + Op.SSTORE( + slot_violation_result, call_dipped_into_reserve() + ) + contract_address = pre.deploy_contract(contract) + sender = pre.fund_eoa(balance) + + wallet = Op.CALL(address=contract_address, value=value) + if credit_value is not None: + creditor = pre.deploy_contract( + # Use SELFDESTRUCT in order to avoid CALL and an infinite loop but + # still send all value + Op.SELFDESTRUCT(address=sender), + balance=credit_value, + ) + wallet += Op.CALL(address=creditor) + wallet += Op.SSTORE( + slot_violation_result, call_dipped_into_reserve() + ) + refill_call(Op.ADDRESS) + wallet_address = pre.deploy_contract(code=wallet) + + authorization_list = [ + AuthorizationTuple(address=wallet_address, nonce=0, signer=sender) + ] + + tx_1 = Transaction( + gas_limit=generous_gas(fork), + to=sender, + sender=pre.fund_eoa(), + authorization_list=authorization_list, + ) + first_result = 1 if violation else 0 + # Second result is written after crediting. + second_result = 1 if violation and not credit_value else 0 + + contract_storage = { + slot_violation_result: first_result, + slot_code_worked: value_code_worked, + } + wallet_storage = {slot_violation_result: second_result} + blockchain_test( + pre=pre, + post={ + contract_address: Account(storage=contract_storage), + sender: Account(storage=wallet_storage), + }, + blocks=[Block(txs=[tx_1])], + ) + + +# NOTE: test_credit_with_transaction_fee from reserve_balance tests does not +# apply for dippedIntoReserve() +# NOTE: test_access_lists from reserve_balance tests does not +# apply for dippedIntoReserve() + + +@pytest.mark.parametrize( + ["value", "balance", "violation"], + [ + pytest.param(0, Spec.RESERVE_BALANCE, False, id="zero_value"), + pytest.param(1, Spec.RESERVE_BALANCE, True, id="non_zero_value"), + pytest.param( + 1, Spec.RESERVE_BALANCE + 1, False, id="non_zero_value_good" + ), + ], +) +@pytest.mark.parametrize("pre_delegated", [True, False]) +@pytest.mark.with_all_contract_creating_tx_types +def test_creation_tx( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + refill_factory: RefillFactory, + value: int, + balance: int, + violation: bool, + pre_delegated: bool, + tx_type: int, + fork: Fork, +) -> None: + """ + Test reserve balance violations for creation txs to: null. + """ + refill_call = refill_factory() + pre_fund_value = 0 + if pre_delegated: + sender = pre.fund_eoa(balance, delegation=Address(0x1111)) + else: + sender = pre.fund_eoa(balance) + + initcode = Initcode( + deploy_code=Op.STOP, + initcode_prefix=Op.SSTORE( + slot_violation_result, call_dipped_into_reserve() + ) + + refill_call(Op.ORIGIN), + fork=fork, + ) + + tx_1 = Transaction( + gas_limit=generous_gas(fork), + to=None, + ty=tx_type, + value=value, + sender=sender, + data=initcode, + ) + new_address = tx_1.created_contract + + expected_violation = 1 if (violation and pre_delegated) else 0 + + blockchain_test( + pre=pre, + post={ + new_address: Account( + code=Op.STOP, + balance=value + pre_fund_value, + storage={slot_violation_result: expected_violation}, + ) + }, + blocks=[Block(txs=[tx_1])], + ) + + +@pytest.mark.parametrize( + ["value", "balance"], + [ + pytest.param(0, Spec.RESERVE_BALANCE, id="zero_value"), + pytest.param(1, Spec.RESERVE_BALANCE, id="non_zero_value"), + pytest.param(1, Spec.RESERVE_BALANCE + 1, id="non_zero_value_good"), + ], +) +@pytest.mark.parametrize("pre_delegated", [True, False]) +@pytest.mark.parametrize("pre_funded", [True, False]) +def test_contract_unrestricted( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + value: int, + balance: int, + pre_delegated: bool, + pre_funded: bool, + fork: Fork, +) -> None: + """ + Test reserve balance never affects contract spends and + dippedIntoReserve() always returns 0. + """ + transfer_destination = Address(0x1121) + if pre_delegated: + sender = pre.fund_eoa( + Spec.RESERVE_BALANCE + balance, delegation=Address(0x1111) + ) + else: + sender = pre.fund_eoa(Spec.RESERVE_BALANCE + balance) + + caller = ( + Op.CALL(value=value, address=transfer_destination) + + Op.SSTORE(slot_code_worked, value_code_worked) + + Op.SSTORE(slot_violation_result, call_dipped_into_reserve()) + ) + caller_address = pre.deploy_contract( + caller, balance=balance if pre_funded else 0 + ) + + tx_1 = Transaction( + gas_limit=generous_gas(fork), + to=caller_address, + value=balance if not pre_funded else 0, + sender=sender, + ) + storage = {slot_code_worked: value_code_worked, slot_violation_result: 0} + + blockchain_test( + pre=pre, + post={ + caller_address: Account(storage=storage, balance=balance - value), + transfer_destination: Account(balance=value) + if value != 0 + else None, + }, + blocks=[Block(txs=[tx_1])], + ) + + +@pytest.mark.parametrize( + ["value", "balance"], + [ + pytest.param(0, Spec.RESERVE_BALANCE, id="zero_value"), + pytest.param(1, Spec.RESERVE_BALANCE, id="non_zero_value"), + pytest.param(1, Spec.RESERVE_BALANCE + 1, id="non_zero_value_good"), + ], +) +@pytest.mark.parametrize("pre_delegated", [True, False]) +@pytest.mark.parametrize("pre_funded", [True, False]) +@pytest.mark.parametrize("selfdestruct", [True, False]) +@pytest.mark.with_all_create_opcodes +def test_contract_unrestricted_with_create( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + value: int, + balance: int, + pre_delegated: bool, + pre_funded: bool, + selfdestruct: bool, + create_opcode: Op, + fork: Fork, +) -> None: + """ + Test reserve balance never affects contract spends done with a + create opcode, and dippedIntoReserve() respects this. + """ + if pre_delegated: + sender = pre.fund_eoa( + Spec.RESERVE_BALANCE + balance, delegation=Address(0x1111) + ) + else: + sender = pre.fund_eoa(Spec.RESERVE_BALANCE + balance) + + selfdestruct_target = Address(0x5656) + + initcode = ( + Op.SELFDESTRUCT(address=selfdestruct_target) + if selfdestruct + else Initcode(deploy_code=Op.STOP) + ) + initcode_bytes = initcode + b"\x00" * (32 - (len(initcode) % 32)) + + factory = ( + Op.MSTORE(0, Op.PUSH32(bytes(initcode_bytes))) + + create_opcode(value=value, size=len(initcode)) + + Op.SSTORE(slot_code_worked, value_code_worked) + + Op.SSTORE(slot_violation_result, call_dipped_into_reserve()) + ) + factory_address = pre.deploy_contract( + factory, balance=balance if pre_funded else 0 + ) + + new_contract_address = compute_create_address( + address=factory_address, + nonce=1, + initcode=initcode, + opcode=create_opcode, + ) + + tx_1 = Transaction( + gas_limit=generous_gas(fork), + to=factory_address, + value=balance if not pre_funded else 0, + sender=sender, + ) + storage = {slot_code_worked: value_code_worked, slot_violation_result: 0} + + blockchain_test( + pre=pre, + post={ + factory_address: Account(storage=storage, balance=balance - value), + new_contract_address: Account(balance=value, code=Op.STOP) + if not selfdestruct + else None, + selfdestruct_target: Account(balance=value) + if selfdestruct and value != 0 + else None, + }, + blocks=[Block(txs=[tx_1])], + ) + + +@pytest.mark.parametrize("prefund_balance", [0, Spec.RESERVE_BALANCE // 2]) +@pytest.mark.parametrize("create_balance", [0, Spec.RESERVE_BALANCE // 2]) +@pytest.mark.parametrize("call_balance", [0, Spec.RESERVE_BALANCE // 2]) +@pytest.mark.parametrize("pull_balance", [0, Spec.RESERVE_BALANCE // 2]) +@pytest.mark.parametrize("same_tx", [True, False]) +@pytest.mark.parametrize("through_delegation", [True, False]) +@pytest.mark.with_all_create_opcodes +def test_contract_unrestricted_with_selfdestruct( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + refill_factory: RefillFactory, + # Balance available to spender in previous transaction + prefund_balance: int, + # Balance available to spender in create + create_balance: int, + # Balance available to spender in selfdestruct call + call_balance: int, + # Balance the spender pulls in the selfdestruct call + pull_balance: int, + # Whether the selfdestructing call happens in the same tx + # as the creation (c.f. EIP-6780) + same_tx: bool, + # Whether the SELFDESTRUCT should be called on behalf of + # a delegating account + through_delegation: bool, + create_opcode: Op, + fork: Fork, +) -> None: + """ + Test dippedIntoReserve() for contract spends done with a selfdestruct + opcode, including when called on behalf of a delegating EOA. + + We allow the selfdestructing contract to be funded in various + stages of the execution. + """ + refill_call = refill_factory() if through_delegation else None + + value = prefund_balance + call_balance + pull_balance + delegated_address = pre.fund_eoa(amount=0) + + if through_delegation: + # If we're delegating to the selfdestructing account, + # the endowment given at creation will not be included + # in the SELFDESTRUCT transfer. + pass + else: + value += create_balance + + selfdestruct_target = Address(0x5656) + pull_funder_address = pre.deploy_contract( + Op.SELFDESTRUCT(address=Op.CALLER), balance=pull_balance + ) + + deploy_code = Op.CALL(address=pull_funder_address) + Op.SELFDESTRUCT( + address=selfdestruct_target + ) + initcode = Initcode(deploy_code=deploy_code) + + new_address_offset = 0 + initcode_offset = 32 + + factory = ( + Op.SSTORE(slot_code_worked, value_code_worked) + + Op.CALLDATACOPY(initcode_offset, 0, len(initcode)) + # create new contract and store its address to later call it + + Op.MSTORE( + new_address_offset, + create_opcode( + value=create_balance, + offset=initcode_offset, + size=len(initcode), + ), + ) + ) + if same_tx: + factory += Op.CALL( + address=delegated_address + if through_delegation + else Op.MLOAD(new_address_offset), + value=call_balance, + ) + factory += Op.SSTORE(slot_violation_result, call_dipped_into_reserve()) + if refill_call is not None: + factory += refill_call(delegated_address) + + factory_address = pre.deploy_contract( + factory, + balance=create_balance + (call_balance if same_tx else 0), + ) + + new_contract_address = compute_create_address( + address=factory_address, + nonce=1, + initcode=initcode, + opcode=create_opcode, + ) + + txs = [] + if prefund_balance > 0: + txs.append( + Transaction( + to=delegated_address + if through_delegation + else new_contract_address, + value=prefund_balance, + sender=pre.fund_eoa(), + ), + ) + + # The creating transaction. If same_tx is also the test tx. + txs.append( + Transaction( + gas_limit=generous_gas(fork), + to=factory_address, + sender=pre.fund_eoa(), + data=initcode, + authorization_list=[ + AuthorizationTuple( + address=new_contract_address, + nonce=0, + signer=delegated_address, + ) + ] + if through_delegation + else None, + ) + ) + + # Intermediate contract which calls into the tested account to trigger + # its selfdestruct and persist dipped_into_reserve result. + caller_code = Op.CALL( + address=delegated_address + if through_delegation + else new_contract_address, + value=call_balance, + ) + Op.SSTORE(slot_violation_result, call_dipped_into_reserve()) + if refill_call is not None: + caller_code += refill_call(delegated_address) + caller_address = pre.deploy_contract(caller_code, balance=call_balance) + + if not same_tx: + # A separate test tx follows the creating tx. + txs.append( + Transaction( + gas_limit=generous_gas(fork), + to=caller_address, + sender=pre.fund_eoa(), + ) + ) + + expected_violation = ( + 1 if through_delegation and value > 0 and prefund_balance > 0 else 0 + ) + + factory_storage = {slot_code_worked: value_code_worked} + if same_tx: + factory_storage[slot_violation_result] = expected_violation + + post = { + # Factory is the caller to store result if same_tx + # Factory is always left with no balance. + factory_address: Account(storage=factory_storage, balance=0), + # caller_address is the caller to store result if not same_tx + caller_address: Account( + storage={slot_violation_result: expected_violation} + if not same_tx + else {} + ), + # Deployed contract will remain if + # - destructs not in same tx (EIP-6780) + # - it destructs the delegating account + new_contract_address: Account( + balance=create_balance if through_delegation else 0, + code=deploy_code, + ) + if not same_tx or through_delegation + else None, + # SELFDESTRUCT target is deleted if source was empty + selfdestruct_target: Account(balance=value) if value != 0 else None, + } + + blockchain_test( + pre=pre, + post=post, + blocks=[Block(txs=txs)], + ) + + +# NOTE: skip: +# - test_contract_unrestricted_within_initcode +# - test_unrestricted_in_creation_tx_initcode +# as not providing additional coverage + + +@pytest.mark.parametrize("stage1", Stage1Balance) +@pytest.mark.parametrize("stage2", StageBalance) +@pytest.mark.parametrize("stage3", StageBalance) +def test_two_step_balance_change( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + refill_factory: RefillFactory, + fork: Fork, + stage1: Stage1Balance, + stage2: StageBalance, + stage3: StageBalance, +) -> None: + """ + Test dippedIntoReserve() when a delegated account's balance changes + in 2 steps. + """ + refill_call = refill_factory() + + balance1 = stage1.compute_balance() + balance2 = stage2.compute_balance([balance1]) + balance3 = stage3.compute_balance([balance1, balance2]) + + delta1 = balance2 - balance1 + delta2 = balance3 - balance2 + + sink = Address(0x5111) + + wallet_code = Op.CALL(address=sink, value=Op.CALLDATALOAD(0)) + wallet_address = pre.deploy_contract(code=wallet_code) + + sender = pre.fund_eoa(balance1, delegation=wallet_address) + + contract_code = Op.SSTORE(slot_code_worked, value_code_worked) + + if delta1 <= 0: + contract_code += Op.MSTORE(0, -delta1) + contract_code += Op.CALL(address=sender, args_size=32) + elif delta1 > 0: + funder1 = pre.deploy_contract( + code=Op.SELFDESTRUCT(sender), + balance=delta1, + ) + contract_code += Op.CALL(address=funder1) + + contract_code += Op.SSTORE( + slot_violation_after_stage2, call_dipped_into_reserve() + ) + + if delta2 <= 0: + contract_code += Op.MSTORE(0, -delta2) + contract_code += Op.CALL(address=sender, args_size=32) + elif delta2 > 0: + funder2 = pre.deploy_contract( + code=Op.SELFDESTRUCT(sender), + balance=delta2, + ) + contract_code += Op.CALL(address=funder2) + + contract_code += Op.SSTORE( + slot_violation_after_stage3, call_dipped_into_reserve() + ) + refill_call(sender) + + contract_address = pre.deploy_contract(contract_code) + + tx = Transaction( + gas_limit=generous_gas(fork), + to=contract_address, + sender=sender, + ) + + violation_after_stage2 = ( + balance2 < balance1 and balance2 < Spec.RESERVE_BALANCE + ) + violation_after_stage3 = ( + balance3 < balance1 and balance3 < Spec.RESERVE_BALANCE + ) + + storage = { + slot_code_worked: value_code_worked, + slot_violation_after_stage2: 1 if violation_after_stage2 else 0, + slot_violation_after_stage3: 1 if violation_after_stage3 else 0, + } + + blockchain_test( + pre=pre, + post={contract_address: Account(storage=storage)}, + blocks=[Block(txs=[tx])], + ) + + +@pytest.mark.parametrize( + "violation_index_fn", + [ + pytest.param(lambda _n: None, id="no_violation"), + pytest.param(lambda _n: 0, id="first_violates"), + pytest.param(lambda n: n // 2, id="middle_violates"), + pytest.param(lambda n: n - 1, id="last_violates"), + ], +) +def test_many_accounts_balance_change( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + refill_factory: RefillFactory, + violation_index_fn: Callable[[int], int | None], + fork: Fork, +) -> None: + """ + Test dippedIntoReserve() with many accounts having their balance changed. + + A single wallet is deployed and many EOAs delegate to it. Each EOA sends + a transfer when the wallet code executes. The violation_index parameter + determines which account (if any) ends up in violation. + + The number of accounts to involve depends on how cheaply can we call them + and on the transaction gas limit cap. + """ + gas_costs = fork.gas_costs() + gas_per_account = ( + Op.CALL( + # Warmed using access lists for cheapest call + address_warm=True, + value_transfer=True, + account_new=False, + delegated_address=True, + # Warmed using access lists for cheapest call + delegated_address_warm=True, + ).gas_cost(fork) + + gas_costs.G_ACCESS_LIST_ADDRESS + ) + gas_limit = fork.transaction_gas_limit_cap() + assert gas_limit is not None + # Using generous_gas(fork) as margin for constant gas expenses. + num_accounts = (gas_limit - generous_gas(fork)) // gas_per_account + assert ( + num_accounts >= 2550 + ) # 2570 minus margin for refill/precompile overhead + violation_index = violation_index_fn(num_accounts) + + refill_call = refill_factory() + value = 1 + + initial_sink_balance = 1 + sink_address = pre.fund_eoa(initial_sink_balance) + wallet_code = Op.CALL(address=sink_address, value=value) + wallet_address = pre.deploy_contract(code=wallet_code) + + senders = [] + for i in range(num_accounts): + if i == violation_index: + balance = Spec.RESERVE_BALANCE + else: + balance = Spec.RESERVE_BALANCE + value + senders.append(pre.fund_eoa(balance, delegation=wallet_address)) + + contract_code = Op.SSTORE(slot_code_worked, value_code_worked) + for sender in senders: + contract_code += Op.CALL(address=sender) + Op.POP + contract_code += Op.SSTORE( + slot_violation_result, call_dipped_into_reserve() + ) + if violation_index is not None: + contract_code += refill_call(senders[violation_index]) + contract_address = pre.deploy_contract(contract_code) + + tx = Transaction( + gas_limit=gas_limit, + to=contract_address, + sender=pre.fund_eoa(), + access_list=[AccessList(address=s, storage_keys=[]) for s in senders] + + [AccessList(address=wallet_address, storage_keys=[])], + ) + + expected_violation = 1 if violation_index is not None else 0 + total_sent = value * num_accounts + + blockchain_test( + pre=pre, + post={ + contract_address: Account( + storage={ + slot_code_worked: value_code_worked, + slot_violation_result: expected_violation, + } + ), + sink_address: Account(balance=initial_sink_balance + total_sent), + }, + blocks=[Block(txs=[tx])], + ) diff --git a/tests/monad_nine/mip4_checkreservebalance/test_tx_revert.py b/tests/monad_nine/mip4_checkreservebalance/test_tx_revert.py new file mode 100644 index 0000000000..4452c930e0 --- /dev/null +++ b/tests/monad_nine/mip4_checkreservebalance/test_tx_revert.py @@ -0,0 +1,118 @@ +""" +Tests for reserve balance transaction revert mechanism. + +Tests verify that the reserve balance transaction reversion rules +from MONAD_EIGHT are not affected by the new precompile in MIP-4. +""" + +import pytest +from execution_testing import ( + Account, + Address, + Alloc, + Block, + BlockchainTestFiller, + Op, + Transaction, +) +from execution_testing.forks import MONAD_NEXT +from execution_testing.forks.helpers import Fork + +from .helpers import ( + RefillFactory, + call_dipped_into_reserve, + generous_gas, +) +from .spec import ( + Spec, + ref_spec_mip4, +) + +REFERENCE_SPEC_GIT_PATH = ref_spec_mip4.git_path +REFERENCE_SPEC_VERSION = ref_spec_mip4.version + +slot_code_worked = 0x1 +value_code_worked = 0x1234 +slot_violation_result = 0x10 + +pytestmark = [ + pytest.mark.valid_from("MONAD_EIGHT"), + pytest.mark.pre_alloc_group( + "mip4_tx_revert_tests", + reason="Tests reserve balance tx revert mechanism", + ), +] + + +@pytest.mark.parametrize( + "violation_for_check,violation_for_tx_revert", + [ + pytest.param(False, False, id="no_violation-success"), + pytest.param(False, True, id="no_violation-revert"), + pytest.param(True, False, id="violation-refill-success"), + pytest.param(True, True, id="violation-no_refill-revert"), + ], +) +def test_precompile_does_not_alter_revert_mechanism( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + refill_factory: RefillFactory, + fork: Fork, + violation_for_check: bool, + violation_for_tx_revert: bool, +) -> None: + """ + Test that calling the precompile doesn't alter the reserve balance + violation revert mechanism at the end of the transaction. + + The sender is always delegated. The contract controls when violation + occurs by calling back to the sender (which triggers wallet code that + sends value, depleting the sender's balance below reserve). + """ + refill_call = refill_factory() + + wallet_code = Op.CALL(address=Address(0x0111), value=1) + wallet_address = pre.deploy_contract(code=wallet_code) + + if not violation_for_check and not violation_for_tx_revert: + initial_balance = 2 * Spec.RESERVE_BALANCE + else: + initial_balance = Spec.RESERVE_BALANCE + + sender = pre.fund_eoa(initial_balance, delegation=wallet_address) + + contract_code = Op.SSTORE(slot_code_worked, value_code_worked) + + if violation_for_check: + contract_code += Op.CALL(address=sender) + + if fork >= MONAD_NEXT: + contract_code += Op.SSTORE( + slot_violation_result, call_dipped_into_reserve() + ) + + if not violation_for_tx_revert: + contract_code += refill_call(sender) + elif not violation_for_check: + contract_code += Op.CALL(address=sender) + + contract_address = pre.deploy_contract(contract_code) + + tx = Transaction( + gas_limit=generous_gas(fork), + to=contract_address, + sender=sender, + ) + + if violation_for_tx_revert: + storage = {} + else: + storage = {slot_code_worked: value_code_worked} + if fork >= MONAD_NEXT: + storage[slot_violation_result] = 1 if violation_for_check else 0 + + blockchain_test( + pre=pre, + post={contract_address: Account(storage=storage)}, + blocks=[Block(txs=[tx])], + ) From 7c2787214b4c97943af4bf5750298bf28bd341a4 Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Mon, 2 Mar 2026 10:16:41 +0100 Subject: [PATCH 3/9] Generic precompile tests aware of Monad precompiles --- tests/frontier/precompiles/test_precompile_absence.py | 10 +++++++++- tests/frontier/precompiles/test_precompiles.py | 2 ++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/frontier/precompiles/test_precompile_absence.py b/tests/frontier/precompiles/test_precompile_absence.py index 154a6b2cde..09966cc6a1 100644 --- a/tests/frontier/precompiles/test_precompile_absence.py +++ b/tests/frontier/precompiles/test_precompile_absence.py @@ -25,12 +25,16 @@ pytest.param(32, id="32_bytes"), ], ) +# Should we shift the tested address range upwards to the +# range where Monad precompiles are put +@pytest.mark.parametrize("monad_range", [True, False]) @pytest.mark.valid_from("Byzantium") def test_precompile_absence( state_test: StateTestFiller, pre: Alloc, fork: Fork, calldata_size: int, + monad_range: bool, ) -> None: """ Test that addresses close to zero are not precompiles unless active in the @@ -39,9 +43,13 @@ def test_precompile_absence( active_precompiles = fork.precompiles() storage = Storage() call_code = Bytecode() - for address in range(1, UPPER_BOUND + 1): + offset = 0x1000 - UPPER_BOUND // 2 if monad_range else 1 + for address in range(offset, UPPER_BOUND + offset): if Address(address) in active_precompiles: continue + if address == 0x1000: + # Monad Staking Precompile not implemented yet + continue call_code += Op.SSTORE( address, Op.CALL(gas=0, address=address, args_size=calldata_size), diff --git a/tests/frontier/precompiles/test_precompiles.py b/tests/frontier/precompiles/test_precompiles.py index 3326daf6f0..64fb49b713 100644 --- a/tests/frontier/precompiles/test_precompiles.py +++ b/tests/frontier/precompiles/test_precompiles.py @@ -82,6 +82,8 @@ def test_precompiles( precompiled contract exists at the given address. """ + if address == 0x1000: + pytest.skip("Monad Staking Precompile not implemented yet") env = Environment() # Empty account to serve as reference From d5b842bcaa78550963de7a0eec2515a68338ddfe Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Wed, 4 Mar 2026 09:02:24 +0100 Subject: [PATCH 4/9] Move & rename MONAD_NEXT to MONAD_NINE (post-rebase) --- .../mip4_checkreservebalance/test_fork_transition.py | 2 +- .../monad_nine/mip4_checkreservebalance/test_multi_block.py | 2 +- .../mip4_checkreservebalance/test_precompile_call.py | 2 +- tests/monad_nine/mip4_checkreservebalance/test_transfers.py | 2 +- tests/monad_nine/mip4_checkreservebalance/test_tx_revert.py | 6 +++--- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/monad_nine/mip4_checkreservebalance/test_fork_transition.py b/tests/monad_nine/mip4_checkreservebalance/test_fork_transition.py index a48df99dcd..b7654e3cfd 100644 --- a/tests/monad_nine/mip4_checkreservebalance/test_fork_transition.py +++ b/tests/monad_nine/mip4_checkreservebalance/test_fork_transition.py @@ -23,7 +23,7 @@ REFERENCE_SPEC_VERSION = ref_spec_mip4.version -@pytest.mark.valid_at_transition_to("MONAD_NEXT", subsequent_forks=True) +@pytest.mark.valid_at_transition_to("MONAD_NINE", subsequent_forks=True) def test_fork_transition( blockchain_test: BlockchainTestFiller, pre: Alloc, diff --git a/tests/monad_nine/mip4_checkreservebalance/test_multi_block.py b/tests/monad_nine/mip4_checkreservebalance/test_multi_block.py index 2a5767a833..635f86d24d 100644 --- a/tests/monad_nine/mip4_checkreservebalance/test_multi_block.py +++ b/tests/monad_nine/mip4_checkreservebalance/test_multi_block.py @@ -29,7 +29,7 @@ slot_violation_result = 0x2 pytestmark = [ - pytest.mark.valid_from("MONAD_NEXT"), + pytest.mark.valid_from("MONAD_NINE"), pytest.mark.pre_alloc_group( "mip4_reserve_balance_introspection_tests", reason="Tests reserve balance introspection", diff --git a/tests/monad_nine/mip4_checkreservebalance/test_precompile_call.py b/tests/monad_nine/mip4_checkreservebalance/test_precompile_call.py index 32186fb5af..3b6a932abf 100644 --- a/tests/monad_nine/mip4_checkreservebalance/test_precompile_call.py +++ b/tests/monad_nine/mip4_checkreservebalance/test_precompile_call.py @@ -161,7 +161,7 @@ def call_code( pytestmark = [ - pytest.mark.valid_from("MONAD_NEXT"), + pytest.mark.valid_from("MONAD_NINE"), pytest.mark.pre_alloc_group( "mip4_checkreservebalance_tests", reason="Tests reserve balance precompile", diff --git a/tests/monad_nine/mip4_checkreservebalance/test_transfers.py b/tests/monad_nine/mip4_checkreservebalance/test_transfers.py index ce2acda807..9f04a7e437 100644 --- a/tests/monad_nine/mip4_checkreservebalance/test_transfers.py +++ b/tests/monad_nine/mip4_checkreservebalance/test_transfers.py @@ -42,7 +42,7 @@ slot_violation_after_stage3 = 0x13 pytestmark = [ - pytest.mark.valid_from("MONAD_NEXT"), + pytest.mark.valid_from("MONAD_NINE"), pytest.mark.pre_alloc_group( "mip4_checkreservebalance_tests", reason="Tests reserve balance precompile", diff --git a/tests/monad_nine/mip4_checkreservebalance/test_tx_revert.py b/tests/monad_nine/mip4_checkreservebalance/test_tx_revert.py index 4452c930e0..711891cdf1 100644 --- a/tests/monad_nine/mip4_checkreservebalance/test_tx_revert.py +++ b/tests/monad_nine/mip4_checkreservebalance/test_tx_revert.py @@ -15,7 +15,7 @@ Op, Transaction, ) -from execution_testing.forks import MONAD_NEXT +from execution_testing.forks import MONAD_NINE from execution_testing.forks.helpers import Fork from .helpers import ( @@ -86,7 +86,7 @@ def test_precompile_does_not_alter_revert_mechanism( if violation_for_check: contract_code += Op.CALL(address=sender) - if fork >= MONAD_NEXT: + if fork >= MONAD_NINE: contract_code += Op.SSTORE( slot_violation_result, call_dipped_into_reserve() ) @@ -108,7 +108,7 @@ def test_precompile_does_not_alter_revert_mechanism( storage = {} else: storage = {slot_code_worked: value_code_worked} - if fork >= MONAD_NEXT: + if fork >= MONAD_NINE: storage[slot_violation_result] = 1 if violation_for_check else 0 blockchain_test( From aa0a1126a95ec1eea34daa2d77a8a7ff003e7695 Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Wed, 4 Mar 2026 09:48:18 +0100 Subject: [PATCH 5/9] Revert "Gas-dependent error message for `method not supported`" This reverts commit 1f901ab34204b2eea6e42f119ac94afc9d642ef7. --- .../precompiled_contracts/reserve_balance.py | 23 +++--------- .../mip4_checkreservebalance/spec.py | 3 -- .../test_precompile_call.py | 37 ++++++------------- 3 files changed, 17 insertions(+), 46 deletions(-) diff --git a/src/ethereum/forks/monad_nine/vm/precompiled_contracts/reserve_balance.py b/src/ethereum/forks/monad_nine/vm/precompiled_contracts/reserve_balance.py index 240269426d..7c0738cd67 100644 --- a/src/ethereum/forks/monad_nine/vm/precompiled_contracts/reserve_balance.py +++ b/src/ethereum/forks/monad_nine/vm/precompiled_contracts/reserve_balance.py @@ -11,7 +11,7 @@ Implementation of the RESERVE BALANCE precompiled contract for MIP-4. """ -from ethereum_types.numeric import U256, Uint +from ethereum_types.numeric import U256 from ...vm import Evm from ...vm.exceptions import InvalidParameter, RevertInMonadPrecompile @@ -21,8 +21,6 @@ # keccak256("dippedIntoReserve()")[:4].hex() == "3a61584e" DIPPED_INTO_RESERVE_SELECTOR = bytes.fromhex("3a61584e") -GAS_ERROR_THRESHOLD = Uint(40000) - def _is_call(evm: Evm) -> bool: # STATICCALL: is_static is True @@ -48,12 +46,9 @@ def reserve_balance(evm: Evm) -> None: "value is nonzero" when called with a nonzero value. Calldata must be exactly the 4-byte function selector (0x3a61584e). - If the selector does not match or calldata is shorter than 4 bytes, - the precompile reverts. The error message "method not supported" is - only included when the gas provided to the call frame is at least - GAS_ERROR_THRESHOLD; otherwise the revert carries no return data. - If extra calldata is appended beyond the selector, the precompile - reverts with "input is invalid". + If the selector does not match, the precompile reverts with "method + not supported". If extra calldata is appended beyond the selector, + the precompile reverts with "input is invalid". Reverts consume all gas provided to the call frame. @@ -75,17 +70,11 @@ def reserve_balance(evm: Evm) -> None: charge_gas(evm, GAS_WARM_ACCESS) if len(data) < 4: - if evm.message.gas >= GAS_ERROR_THRESHOLD: - evm.output = b"method not supported" - else: - evm.output = b"" + evm.output = b"method not supported" raise RevertInMonadPrecompile if data[:4] != DIPPED_INTO_RESERVE_SELECTOR: - if evm.message.gas >= GAS_ERROR_THRESHOLD: - evm.output = b"method not supported" - else: - evm.output = b"" + evm.output = b"method not supported" raise RevertInMonadPrecompile if evm.message.value != 0: diff --git a/tests/monad_nine/mip4_checkreservebalance/spec.py b/tests/monad_nine/mip4_checkreservebalance/spec.py index 5704b70d79..40949817c0 100644 --- a/tests/monad_nine/mip4_checkreservebalance/spec.py +++ b/tests/monad_nine/mip4_checkreservebalance/spec.py @@ -26,9 +26,6 @@ class Spec: # Aligns with G_WARM_ACCOUNT_ACCESS at time of MIP-4. GAS_COST = 100 - # If gas is at or above this, ERROR_METHOD_NOT_SUPPORTED may - # be returned on revert. - GAS_ERROR_THRESHOLD = 40000 # keccak256("dippedIntoReserve()")[:4].hex() == "3a61584e" DIPPED_INTO_RESERVE_SELECTOR = bytes.fromhex("3A61584E") diff --git a/tests/monad_nine/mip4_checkreservebalance/test_precompile_call.py b/tests/monad_nine/mip4_checkreservebalance/test_precompile_call.py index 3b6a932abf..25d1d9be38 100644 --- a/tests/monad_nine/mip4_checkreservebalance/test_precompile_call.py +++ b/tests/monad_nine/mip4_checkreservebalance/test_precompile_call.py @@ -62,7 +62,8 @@ def should_succeed(self) -> bool: """Return whether this scenario results in a successful call.""" return self == CallScenario.SUCCESS - def error_message(self, gas: int = Spec.GAS_COST) -> bytes | None: + @property + def error_message(self) -> bytes | None: """Return raw ASCII error bytes for this scenario, or None.""" match self: case ( @@ -72,9 +73,7 @@ def error_message(self, gas: int = Spec.GAS_COST) -> bytes | None: ): return None case CallScenario.WRONG_SELECTOR | CallScenario.SHORT_CALLDATA: - if gas >= Spec.GAS_ERROR_THRESHOLD: - return Spec.ERROR_METHOD_NOT_SUPPORTED.encode() - return None + return Spec.ERROR_METHOD_NOT_SUPPORTED.encode() case CallScenario.EXTRA_CALLDATA: return Spec.ERROR_INPUT_INVALID.encode() case CallScenario.NONZERO_VALUE: @@ -193,12 +192,10 @@ def _mload_of(msg: bytes) -> int: pytest.param(SpecMIP3.MAX_TX_MEMORY_USAGE, id="max"), ], ) -@pytest.mark.parametrize("gas", [Spec.GAS_COST, Spec.GAS_ERROR_THRESHOLD]) def test_input_size( blockchain_test: BlockchainTestFiller, pre: Alloc, input_size: int, - gas: int, fork: Fork, ) -> None: """ @@ -212,7 +209,7 @@ def test_input_size( + Op.SSTORE( slot_call_success, Op.CALL( - gas=gas, + gas=10000, address=Spec.RESERVE_BALANCE_PRECOMPILE, args_offset=0, args_size=input_size, @@ -237,10 +234,8 @@ def test_input_size( expected_return_size = 32 elif input_size > 4: expected_return_size = len(Spec.ERROR_INPUT_INVALID) - elif gas >= Spec.GAS_ERROR_THRESHOLD: - expected_return_size = len(Spec.ERROR_METHOD_NOT_SUPPORTED) else: - expected_return_size = 0 + expected_return_size = len(Spec.ERROR_METHOD_NOT_SUPPORTED) blockchain_test( pre=pre, @@ -266,12 +261,10 @@ def test_input_size( pytest.param(0x3A61584F, id="off_by_one"), ], ) -@pytest.mark.parametrize("gas", [Spec.GAS_COST, Spec.GAS_ERROR_THRESHOLD]) def test_selector( blockchain_test: BlockchainTestFiller, pre: Alloc, selector: int, - gas: int, fork: Fork, ) -> None: """ @@ -286,7 +279,7 @@ def test_selector( + Op.SSTORE( slot_call_success, Op.CALL( - gas=gas, + gas=10000, address=Spec.RESERVE_BALANCE_PRECOMPILE, args_offset=28, args_size=4, @@ -307,20 +300,15 @@ def test_selector( should_succeed = selector == Spec.DIPPED_INTO_RESERVE_SELECTOR - if should_succeed: - expected_return_size = 32 - elif gas >= Spec.GAS_ERROR_THRESHOLD: - expected_return_size = len(Spec.ERROR_METHOD_NOT_SUPPORTED) - else: - expected_return_size = 0 - blockchain_test( pre=pre, post={ contract_address: Account( storage={ slot_call_success: 1 if should_succeed else 0, - slot_return_size: expected_return_size, + slot_return_size: 32 + if should_succeed + else len(Spec.ERROR_METHOD_NOT_SUPPORTED), slot_code_worked: value_code_worked, } ), @@ -471,7 +459,7 @@ def test_revert_returns( sender=pre.fund_eoa(), ) - err = scenario.error_message(gas) + err = scenario.error_message expected_return_size = ( 32 if scenario.should_succeed else (len(err) if err else 0) ) @@ -633,7 +621,6 @@ def test_call_with_value( ] -@pytest.mark.parametrize("gas", [Spec.GAS_COST, Spec.GAS_ERROR_THRESHOLD]) @pytest.mark.parametrize("scenario1,scenario2", _CHECK_ORDER_PAIRS) def test_check_order( blockchain_test: BlockchainTestFiller, @@ -641,7 +628,6 @@ def test_check_order( fork: Fork, scenario1: CallScenario, scenario2: CallScenario, - gas: int, ) -> None: """ Test precompile check priority with enum-driven failure pairs. @@ -650,7 +636,7 @@ def test_check_order( derives the expected outcome from the higher-priority failure. """ prevailing = min(scenario1, scenario2, key=lambda s: s.check_priority) - expected_msg = prevailing.error_message(gas) or b"" + expected_msg = prevailing.error_message or b"" # Memory layout: non-overlapping buffers; args are at mem[0:64]. ret_offset = 96 @@ -662,7 +648,6 @@ def test_check_order( call_code( scenario1, scenario2, - gas=gas, ret_offset=ret_offset, ret_size=32, ), From eaee82c594a1b3683c20c98f322a699e2885158b Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Fri, 6 Mar 2026 11:25:33 +0100 Subject: [PATCH 6/9] Make the call gas stipend revert scenario compatible --- .../test_precompile_call.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/tests/monad_nine/mip4_checkreservebalance/test_precompile_call.py b/tests/monad_nine/mip4_checkreservebalance/test_precompile_call.py index 25d1d9be38..3f767a8b03 100644 --- a/tests/monad_nine/mip4_checkreservebalance/test_precompile_call.py +++ b/tests/monad_nine/mip4_checkreservebalance/test_precompile_call.py @@ -63,15 +63,15 @@ def should_succeed(self) -> bool: return self == CallScenario.SUCCESS @property - def error_message(self) -> bytes | None: - """Return raw ASCII error bytes for this scenario, or None.""" + def error_message(self) -> bytes: + """Return raw ASCII error bytes for this scenario.""" match self: case ( CallScenario.SUCCESS | CallScenario.NOT_CALL | CallScenario.LOW_GAS ): - return None + return b"" case CallScenario.WRONG_SELECTOR | CallScenario.SHORT_CALLDATA: return Spec.ERROR_METHOD_NOT_SUPPORTED.encode() case CallScenario.EXTRA_CALLDATA: @@ -603,7 +603,6 @@ def test_call_with_value( _INCOMPATIBLE_SCENARIOS = { frozenset({CallScenario.SHORT_CALLDATA, CallScenario.EXTRA_CALLDATA}), - frozenset({CallScenario.LOW_GAS, CallScenario.NONZERO_VALUE}), } _CHECK_ORDER_PAIRS = [ @@ -635,8 +634,14 @@ def test_check_order( Each combination triggers exactly two failure causes. The test derives the expected outcome from the higher-priority failure. """ - prevailing = min(scenario1, scenario2, key=lambda s: s.check_priority) - expected_msg = prevailing.error_message or b"" + if frozenset({scenario1, scenario2}) == frozenset( + {CallScenario.LOW_GAS, CallScenario.NONZERO_VALUE} + ): + # Edge case, where call gas stipend overrides low gas + expected_msg = CallScenario.NONZERO_VALUE.error_message + else: + prevailing = min(scenario1, scenario2, key=lambda s: s.check_priority) + expected_msg = prevailing.error_message or b"" # Memory layout: non-overlapping buffers; args are at mem[0:64]. ret_offset = 96 From 341497195a2339fcc754cd4d355e5d64af9537b8 Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Fri, 6 Mar 2026 11:56:02 +0000 Subject: [PATCH 7/9] fix typo claude insists on fixing... --- tests/monad_nine/mip4_checkreservebalance/test_multi_block.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/monad_nine/mip4_checkreservebalance/test_multi_block.py b/tests/monad_nine/mip4_checkreservebalance/test_multi_block.py index 635f86d24d..9387b091f8 100644 --- a/tests/monad_nine/mip4_checkreservebalance/test_multi_block.py +++ b/tests/monad_nine/mip4_checkreservebalance/test_multi_block.py @@ -73,7 +73,7 @@ def test_exception_rule( fork: Fork, ) -> None: """ - Test reserve balance violations for an EOA sending txs with vaious values, + Test reserve balance violations for an EOA sending txs with various values, where the exception rules are enforced based on txs in various block positions. """ From 2b9d25599dac4f90e163c423a7913370f72f0bd3 Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Fri, 6 Mar 2026 12:56:26 +0000 Subject: [PATCH 8/9] feat(mip4): revert when Monad precompile called via delegating EOA Co-Authored-By: Claude claude-opus --- .../forks/monad_eight/vm/exceptions.py | 11 +++ .../forks/monad_eight/vm/interpreter.py | 11 +++ .../vm/precompiled_contracts/__init__.py | 11 +++ .../forks/monad_nine/vm/interpreter.py | 5 ++ .../vm/precompiled_contracts/__init__.py | 12 ++++ .../test_precompile_call.py | 69 ++++++++++++++++++- .../eip7702_set_code_tx/test_set_code_txs.py | 18 +++-- .../test_set_code_txs_2.py | 18 +++-- 8 files changed, 143 insertions(+), 12 deletions(-) diff --git a/src/ethereum/forks/monad_eight/vm/exceptions.py b/src/ethereum/forks/monad_eight/vm/exceptions.py index 1a75522a0b..7c1473772c 100644 --- a/src/ethereum/forks/monad_eight/vm/exceptions.py +++ b/src/ethereum/forks/monad_eight/vm/exceptions.py @@ -134,6 +134,17 @@ class InvalidParameter(ExceptionalHalt): pass +class RevertInMonadPrecompile(ExceptionalHalt): + """ + Raised by a Monad precompile to revert with an error message. + + Consumes all gas like ExceptionalHalt but preserves evm.output + so the caller sees the revert reason. + """ + + pass + + class InvalidContractPrefix(ExceptionalHalt): """ Raised when the new contract code starts with 0xEF. diff --git a/src/ethereum/forks/monad_eight/vm/interpreter.py b/src/ethereum/forks/monad_eight/vm/interpreter.py index 1aa1ae0466..a05ee10851 100644 --- a/src/ethereum/forks/monad_eight/vm/interpreter.py +++ b/src/ethereum/forks/monad_eight/vm/interpreter.py @@ -53,6 +53,7 @@ set_delegation, ) from ..vm.gas import GAS_CODE_DEPOSIT, charge_gas +from ..vm.precompiled_contracts import MONAD_PRECOMPILE_ADDRESSES from ..vm.precompiled_contracts.mapping import PRE_COMPILED_CONTRACTS from . import Evm from .exceptions import ( @@ -62,6 +63,7 @@ InvalidOpcode, OutOfGasError, Revert, + RevertInMonadPrecompile, RevertOnReserveBalance, StackDepthLimitError, ) @@ -283,6 +285,10 @@ def process_message(message: Message) -> Evm: evm_trace(evm, PrecompileStart(evm.message.code_address)) PRE_COMPILED_CONTRACTS[evm.message.code_address](evm) evm_trace(evm, PrecompileEnd()) + elif evm.message.code_address in MONAD_PRECOMPILE_ADDRESSES: + # Calling a precompile via delegation and it's a Monad + # precompile => revert. + raise RevertInMonadPrecompile else: while evm.running and evm.pc < ulen(evm.code): try: @@ -296,6 +302,11 @@ def process_message(message: Message) -> Evm: evm_trace(evm, EvmStop(Ops.STOP)) + except RevertInMonadPrecompile as error: + evm_trace(evm, OpException(error)) + evm.gas_left = Uint(0) + # evm.output preserved — contains the raw error message + evm.error = error except ExceptionalHalt as error: evm_trace(evm, OpException(error)) evm.gas_left = Uint(0) diff --git a/src/ethereum/forks/monad_eight/vm/precompiled_contracts/__init__.py b/src/ethereum/forks/monad_eight/vm/precompiled_contracts/__init__.py index d32959fc93..ede13f0f8a 100644 --- a/src/ethereum/forks/monad_eight/vm/precompiled_contracts/__init__.py +++ b/src/ethereum/forks/monad_eight/vm/precompiled_contracts/__init__.py @@ -33,6 +33,8 @@ "BLS12_MAP_FP_TO_G1_ADDRESS", "BLS12_MAP_FP2_TO_G2_ADDRESS", "P256VERIFY_ADDRESS", + "STAKING_ADDRESS", + "MONAD_PRECOMPILE_ADDRESSES", ) ECRECOVER_ADDRESS = hex_to_address("0x01") @@ -53,3 +55,12 @@ BLS12_MAP_FP_TO_G1_ADDRESS = hex_to_address("0x10") BLS12_MAP_FP2_TO_G2_ADDRESS = hex_to_address("0x11") P256VERIFY_ADDRESS = hex_to_address("0x100") +STAKING_ADDRESS = hex_to_address("0x1000") + +# Monad-specific precompile addresses: calling these via a delegating EOA +# must revert rather than execute as empty code. +MONAD_PRECOMPILE_ADDRESSES: frozenset = frozenset( + { + STAKING_ADDRESS, + } +) diff --git a/src/ethereum/forks/monad_nine/vm/interpreter.py b/src/ethereum/forks/monad_nine/vm/interpreter.py index 4fc7353ea5..d407775022 100644 --- a/src/ethereum/forks/monad_nine/vm/interpreter.py +++ b/src/ethereum/forks/monad_nine/vm/interpreter.py @@ -54,6 +54,7 @@ set_delegation, ) from ..vm.gas import GAS_CODE_DEPOSIT, charge_gas +from ..vm.precompiled_contracts import MONAD_PRECOMPILE_ADDRESSES from ..vm.precompiled_contracts.mapping import PRE_COMPILED_CONTRACTS from . import Evm from .exceptions import ( @@ -329,6 +330,10 @@ def process_message(message: Message) -> Evm: evm_trace(evm, PrecompileStart(evm.message.code_address)) PRE_COMPILED_CONTRACTS[evm.message.code_address](evm) evm_trace(evm, PrecompileEnd()) + elif evm.message.code_address in MONAD_PRECOMPILE_ADDRESSES: + # Calling a precompile via delegation and it's a Monad + # precompile => revert. + raise RevertInMonadPrecompile else: while evm.running and evm.pc < ulen(evm.code): try: diff --git a/src/ethereum/forks/monad_nine/vm/precompiled_contracts/__init__.py b/src/ethereum/forks/monad_nine/vm/precompiled_contracts/__init__.py index 2c9fb48fd6..dc6440aa16 100644 --- a/src/ethereum/forks/monad_nine/vm/precompiled_contracts/__init__.py +++ b/src/ethereum/forks/monad_nine/vm/precompiled_contracts/__init__.py @@ -34,6 +34,8 @@ "BLS12_MAP_FP2_TO_G2_ADDRESS", "P256VERIFY_ADDRESS", "RESERVE_BALANCE_ADDRESS", + "STAKING_ADDRESS", + "MONAD_PRECOMPILE_ADDRESSES", ) ECRECOVER_ADDRESS = hex_to_address("0x01") @@ -54,4 +56,14 @@ BLS12_MAP_FP_TO_G1_ADDRESS = hex_to_address("0x10") BLS12_MAP_FP2_TO_G2_ADDRESS = hex_to_address("0x11") P256VERIFY_ADDRESS = hex_to_address("0x100") +STAKING_ADDRESS = hex_to_address("0x1000") RESERVE_BALANCE_ADDRESS = hex_to_address("0x1001") + +# Monad-specific precompile addresses: calling these via a delegating EOA +# must revert rather than execute as empty code. +MONAD_PRECOMPILE_ADDRESSES: frozenset = frozenset( + { + STAKING_ADDRESS, + RESERVE_BALANCE_ADDRESS, + } +) diff --git a/tests/monad_nine/mip4_checkreservebalance/test_precompile_call.py b/tests/monad_nine/mip4_checkreservebalance/test_precompile_call.py index 3f767a8b03..d41f5334e6 100644 --- a/tests/monad_nine/mip4_checkreservebalance/test_precompile_call.py +++ b/tests/monad_nine/mip4_checkreservebalance/test_precompile_call.py @@ -13,7 +13,9 @@ import pytest from execution_testing import ( Account, + Address, Alloc, + AuthorizationTuple, Block, BlockchainTestFiller, Bytecode, @@ -54,6 +56,7 @@ class CallScenario(Enum): SHORT_CALLDATA = auto() EXTRA_CALLDATA = auto() NOT_CALL = auto() + DELEGATE_TO_PRECOMPILE = auto() NONZERO_VALUE = auto() LOW_GAS = auto() @@ -69,6 +72,7 @@ def error_message(self) -> bytes: case ( CallScenario.SUCCESS | CallScenario.NOT_CALL + | CallScenario.DELEGATE_TO_PRECOMPILE | CallScenario.LOW_GAS ): return b"" @@ -86,6 +90,7 @@ def check_priority(self) -> int: raise AssertionError("SUCCESS has no check priority") order = [ CallScenario.NOT_CALL, + CallScenario.DELEGATE_TO_PRECOMPILE, CallScenario.LOW_GAS, CallScenario.SHORT_CALLDATA, CallScenario.WRONG_SELECTOR, @@ -101,6 +106,7 @@ def call_code( gas: int | Bytecode = Spec.GAS_COST, ret_offset: int = 0, ret_size: int = 0, + delegating_eoa: Address | None = None, ) -> Bytecode: """ Generate setup + call bytecode for one or more combined scenarios. @@ -148,9 +154,17 @@ def call_code( else: opcode = Op.CALL + if CallScenario.DELEGATE_TO_PRECOMPILE in scenario_set: + assert delegating_eoa is not None, ( + "delegating_eoa required for DELEGATE_TO_PRECOMPILE" + ) + call_address = delegating_eoa + else: + call_address = Spec.RESERVE_BALANCE_PRECOMPILE + return setup + opcode( gas=gas, - address=Spec.RESERVE_BALANCE_PRECOMPILE, + address=call_address, value=value, args_offset=args_offset, args_size=args_size, @@ -440,10 +454,28 @@ def test_revert_returns( ret_offset = 96 rdc_offset = 128 + delegating_eoa: Address | None = None + authorization_list = None + if scenario == CallScenario.DELEGATE_TO_PRECOMPILE: + delegating_eoa = pre.fund_eoa() + authorization_list = [ + AuthorizationTuple( + address=Spec.RESERVE_BALANCE_PRECOMPILE, + nonce=0, + signer=delegating_eoa, + ) + ] + contract = ( Op.SSTORE( slot_call_success, - call_code(scenario, gas=gas, ret_offset=ret_offset, ret_size=32), + call_code( + scenario, + gas=gas, + ret_offset=ret_offset, + ret_size=32, + delegating_eoa=delegating_eoa, + ), ) + Op.SSTORE(slot_return_size, Op.RETURNDATASIZE) + Op.SSTORE(slot_ret_buffer_value, Op.MLOAD(ret_offset)) @@ -457,6 +489,7 @@ def test_revert_returns( gas_limit=generous_gas(fork), to=contract_address, sender=pre.fund_eoa(), + authorization_list=authorization_list, ) err = scenario.error_message @@ -500,11 +533,26 @@ def test_revert_consumes_all_gas( gas_limit = generous_gas(fork) gas_threshold = gas_limit // 64 + delegating_eoa: Address | None = None + authorization_list = None + if scenario == CallScenario.DELEGATE_TO_PRECOMPILE: + delegating_eoa = pre.fund_eoa() + authorization_list = [ + AuthorizationTuple( + address=Spec.RESERVE_BALANCE_PRECOMPILE, + nonce=0, + signer=delegating_eoa, + ) + ] + contract = ( Op.SSTORE(slot_code_worked, value_code_worked) + Op.SSTORE(slot_call_success, 1) + Op.SSTORE(slot_all_gas_consumed, 1) - + Op.SSTORE(slot_call_success, call_code(scenario, gas=Op.GAS)) + + Op.SSTORE( + slot_call_success, + call_code(scenario, gas=Op.GAS, delegating_eoa=delegating_eoa), + ) + Op.SSTORE(slot_all_gas_consumed, Op.LT(Op.GAS, gas_threshold)) ) contract_address = pre.deploy_contract(contract, balance=1) @@ -513,6 +561,7 @@ def test_revert_consumes_all_gas( gas_limit=gas_limit, to=contract_address, sender=pre.fund_eoa(), + authorization_list=authorization_list, ) blockchain_test( @@ -647,6 +696,18 @@ def test_check_order( ret_offset = 96 rdc_offset = 128 + delegating_eoa: Address | None = None + authorization_list = None + if CallScenario.DELEGATE_TO_PRECOMPILE in {scenario1, scenario2}: + delegating_eoa = pre.fund_eoa() + authorization_list = [ + AuthorizationTuple( + address=Spec.RESERVE_BALANCE_PRECOMPILE, + nonce=0, + signer=delegating_eoa, + ) + ] + contract = ( Op.SSTORE( slot_call_success, @@ -655,6 +716,7 @@ def test_check_order( scenario2, ret_offset=ret_offset, ret_size=32, + delegating_eoa=delegating_eoa, ), ) + Op.SSTORE(slot_return_size, Op.RETURNDATASIZE) @@ -668,6 +730,7 @@ def test_check_order( gas_limit=generous_gas(fork), to=contract_address, sender=pre.fund_eoa(), + authorization_list=authorization_list, ) expected_return_size = len(expected_msg) diff --git a/tests/prague/eip7702_set_code_tx/test_set_code_txs.py b/tests/prague/eip7702_set_code_tx/test_set_code_txs.py index 62bde5beff..999a436f9d 100644 --- a/tests/prague/eip7702_set_code_tx/test_set_code_txs.py +++ b/tests/prague/eip7702_set_code_tx/test_set_code_txs.py @@ -2893,7 +2893,7 @@ def test_set_code_to_log( def test_set_code_to_precompile( state_test: StateTestFiller, pre: Alloc, - precompile: int, + precompile: Address, call_opcode: Op, ) -> None: """ @@ -2902,6 +2902,11 @@ def test_set_code_to_precompile( """ auth_signer = pre.fund_eoa(auth_account_start_balance) + # Monad-specific precompiles (address >= 0x1000) revert when called via + # a delegating EOA instead of succeeding as empty code. + is_monad_precompile = int.from_bytes(precompile, "big") >= 0x1000 + expected_success = not is_monad_precompile + if "value" in call_opcode.kwargs: call_bytecode = call_opcode(address=auth_signer, gas=0, value=1) value = 1 @@ -2912,7 +2917,7 @@ def test_set_code_to_precompile( caller_code = ( Op.SSTORE( caller_code_storage.store_next( - call_return_code(opcode=call_opcode, success=True) + call_return_code(opcode=call_opcode, success=expected_success) ), call_bytecode, ) @@ -2955,7 +2960,7 @@ def test_set_code_to_precompile_not_enough_gas_for_precompile_execution( state_test: StateTestFiller, pre: Alloc, fork: Fork, - precompile: int, + precompile: Address, ) -> None: """ Test set code to precompile and making direct call in same transaction with @@ -2984,13 +2989,18 @@ def test_set_code_to_precompile_not_enough_gas_for_precompile_execution( expected_receipt=TransactionReceipt(gas_used=intrinsic_gas), ) + # Monad-specific precompiles (address >= 0x1000) revert when called via + # a delegating EOA, so the value transfer is rolled back. + is_monad_precompile = int.from_bytes(precompile, "big") >= 0x1000 + expected_balance = 1 if is_monad_precompile else 2 + state_test( pre=pre, tx=tx, post={ auth_signer: Account( # implicitly checks no OOG, successful tx transfers ``value=1`` - balance=2, + balance=expected_balance, code=Spec.delegation_designation(Address(precompile)), nonce=1, ), diff --git a/tests/prague/eip7702_set_code_tx/test_set_code_txs_2.py b/tests/prague/eip7702_set_code_tx/test_set_code_txs_2.py index ae3a34ca62..050ba49362 100644 --- a/tests/prague/eip7702_set_code_tx/test_set_code_txs_2.py +++ b/tests/prague/eip7702_set_code_tx/test_set_code_txs_2.py @@ -8,6 +8,7 @@ from execution_testing import ( AccessList, Account, + Address, Alloc, AuthorizationTuple, Block, @@ -533,7 +534,7 @@ def test_pointer_to_precompile( pre: Alloc, sender_delegated: bool, sender_is_auth_signer: bool, - precompile: int, + precompile: Address, ) -> None: """ Tx -> call -> pointer A -> precompile contract. @@ -544,12 +545,16 @@ def test_pointer_to_precompile( with no execution given enough gas. So call to a pointer that points to a precompile is like call to an empty - account + account. """ env = Environment() storage: Storage = Storage() + # Monad-specific precompiles (address >= 0x1000) revert when called via + # a delegating EOA instead of succeeding as empty code. + is_monad_precompile = int.from_bytes(precompile, "big") >= 0x1000 + if sender_delegated: sender_delegation_target = pre.deploy_contract(Op.STOP) sender = pre.fund_eoa(delegation=sender_delegation_target) @@ -575,6 +580,8 @@ def test_pointer_to_precompile( + Op.RETURN(0, 32) ) + pointer_call_result = 0 if is_monad_precompile else 1 + contract_a = pre.deploy_contract( code=Op.CALL( gas=1_000_000, @@ -594,10 +601,11 @@ def test_pointer_to_precompile( ret_offset=1000, ret_size=32, ) - # pointer call to a precompile with 0 gas always return 1 as if calling - # empty address + # pointer call to a precompile with 0 gas always return 1 as if + # calling empty address, except for Monad precompiles which revert. + Op.SSTORE( - storage.store_next(1, "pointer_call_result"), Op.MLOAD(1000) + storage.store_next(pointer_call_result, "pointer_call_result"), + Op.MLOAD(1000), ) ) nonce = ( From 9f71185f7605d248fd3441b8207322d70320ba83 Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:35:54 +0100 Subject: [PATCH 9/9] Apply suggestion from greptile good bot Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- tests/monad_nine/mip4_checkreservebalance/test_transfers.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/monad_nine/mip4_checkreservebalance/test_transfers.py b/tests/monad_nine/mip4_checkreservebalance/test_transfers.py index 9f04a7e437..2dc4298f97 100644 --- a/tests/monad_nine/mip4_checkreservebalance/test_transfers.py +++ b/tests/monad_nine/mip4_checkreservebalance/test_transfers.py @@ -776,12 +776,6 @@ def test_credit_same_tx( expected_violation = ( 1 if violation and value > same_tx_funded_balance else 0 ) - print( - value > same_tx_funded_balance, - value, - same_tx_funded_balance, - balance - value < Spec.RESERVE_BALANCE, - ) storage = { slot_violation_result: expected_violation, slot_code_worked: value_code_worked,