diff --git a/packages/testing/src/execution_testing/forks/forks/forks.py b/packages/testing/src/execution_testing/forks/forks/forks.py index 8c3bec9a0fd..774382a43d7 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_eight/vm/exceptions.py b/src/ethereum/forks/monad_eight/vm/exceptions.py index 1a75522a0b2..7c1473772cd 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 1aa1ae04669..a05ee10851f 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 d32959fc937..ede13f0f8a8 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/exceptions.py b/src/ethereum/forks/monad_nine/vm/exceptions.py index 1a75522a0b2..7c1473772cd 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 73c43e2e213..d407775022f 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,13 +47,14 @@ 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, 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 +64,7 @@ InvalidOpcode, OutOfGasError, Revert, + RevertInMonadPrecompile, RevertOnReserveBalance, StackDepthLimitError, ) @@ -76,6 +79,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: """ @@ -293,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: @@ -306,6 +347,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) @@ -322,34 +368,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 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 d32959fc937..dc6440aa169 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,9 @@ "BLS12_MAP_FP_TO_G1_ADDRESS", "BLS12_MAP_FP2_TO_G2_ADDRESS", "P256VERIFY_ADDRESS", + "RESERVE_BALANCE_ADDRESS", + "STAKING_ADDRESS", + "MONAD_PRECOMPILE_ADDRESSES", ) ECRECOVER_ADDRESS = hex_to_address("0x01") @@ -53,3 +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/src/ethereum/forks/monad_nine/vm/precompiled_contracts/mapping.py b/src/ethereum/forks/monad_nine/vm/precompiled_contracts/mapping.py index 7486203c3e6..e4253b8970a 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 00000000000..7c0738cd674 --- /dev/null +++ b/src/ethereum/forks/monad_nine/vm/precompiled_contracts/reserve_balance.py @@ -0,0 +1,94 @@ +""" +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 + +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") + + +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, 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. + + 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: + evm.output = b"method not supported" + raise RevertInMonadPrecompile + + if data[:4] != DIPPED_INTO_RESERVE_SELECTOR: + evm.output = b"method not supported" + 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/frontier/precompiles/test_precompile_absence.py b/tests/frontier/precompiles/test_precompile_absence.py index 154a6b2cde5..09966cc6a1b 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 3326daf6f03..64fb49b713f 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 diff --git a/tests/monad_nine/__init__.py b/tests/monad_nine/__init__.py new file mode 100644 index 00000000000..fb2be240eb4 --- /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 00000000000..a1476504e9a --- /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 00000000000..43e30434c02 --- /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 00000000000..cb08d10d66c --- /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 00000000000..40949817c0a --- /dev/null +++ b/tests/monad_nine/mip4_checkreservebalance/spec.py @@ -0,0 +1,35 @@ +"""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 + + # 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 00000000000..b7654e3cfd0 --- /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_NINE", 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 00000000000..9387b091f83 --- /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_NINE"), + 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 various 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 00000000000..d41f5334e61 --- /dev/null +++ b/tests/monad_nine/mip4_checkreservebalance/test_precompile_call.py @@ -0,0 +1,752 @@ +""" +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, + Address, + Alloc, + AuthorizationTuple, + 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() + DELEGATE_TO_PRECOMPILE = 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 + + @property + def error_message(self) -> bytes: + """Return raw ASCII error bytes for this scenario.""" + match self: + case ( + CallScenario.SUCCESS + | CallScenario.NOT_CALL + | CallScenario.DELEGATE_TO_PRECOMPILE + | CallScenario.LOW_GAS + ): + return b"" + case CallScenario.WRONG_SELECTOR | CallScenario.SHORT_CALLDATA: + return Spec.ERROR_METHOD_NOT_SUPPORTED.encode() + 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.DELEGATE_TO_PRECOMPILE, + 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, + delegating_eoa: Address | None = None, +) -> 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 + + 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=call_address, + value=value, + args_offset=args_offset, + args_size=args_size, + ret_offset=ret_offset, + ret_size=ret_size, + ) + + +pytestmark = [ + pytest.mark.valid_from("MONAD_NINE"), + 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"), + ], +) +def test_input_size( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + input_size: 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=10000, + 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) + else: + expected_return_size = len(Spec.ERROR_METHOD_NOT_SUPPORTED) + + 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"), + ], +) +def test_selector( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + selector: 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=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 = selector == Spec.DIPPED_INTO_RESERVE_SELECTOR + + 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 len(Spec.ERROR_METHOD_NOT_SUPPORTED), + 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 + + 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, + delegating_eoa=delegating_eoa, + ), + ) + + 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(), + authorization_list=authorization_list, + ) + + err = scenario.error_message + 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 + + 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, delegating_eoa=delegating_eoa), + ) + + 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(), + authorization_list=authorization_list, + ) + + 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}), +} + +_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("scenario1,scenario2", _CHECK_ORDER_PAIRS) +def test_check_order( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, + scenario1: CallScenario, + scenario2: CallScenario, +) -> 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. + """ + 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 + 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, + call_code( + scenario1, + scenario2, + ret_offset=ret_offset, + ret_size=32, + delegating_eoa=delegating_eoa, + ), + ) + + 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(), + authorization_list=authorization_list, + ) + + 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 00000000000..2dc4298f970 --- /dev/null +++ b/tests/monad_nine/mip4_checkreservebalance/test_transfers.py @@ -0,0 +1,1535 @@ +""" +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_NINE"), + 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 + ) + 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 00000000000..711891cdf1b --- /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_NINE +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_NINE: + 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_NINE: + 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])], + ) 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 62bde5beff6..999a436f9de 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 ae3a34ca62a..050ba49362a 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 = (