diff --git a/packages/testing/src/execution_testing/specs/helpers.py b/packages/testing/src/execution_testing/specs/helpers.py index a176cd1c59b..ed0fb8f08d0 100644 --- a/packages/testing/src/execution_testing/specs/helpers.py +++ b/packages/testing/src/execution_testing/specs/helpers.py @@ -322,6 +322,16 @@ def verify_transaction_receipt( expected_value=expected_receipt.cumulative_gas_used, actual_value=actual_receipt.cumulative_gas_used, ) + if ( + expected_receipt.status is not None + and actual_receipt.status != expected_receipt.status + ): + raise TransactionReceiptMismatchError( + index=transaction_index, + field_name="status", + expected_value=expected_receipt.status, + actual_value=actual_receipt.status, + ) if expected_receipt.logs is not None and actual_receipt.logs is not None: actual_logs = actual_receipt.logs expected_logs = expected_receipt.logs diff --git a/tests/monad_eight/reserve_balance/test_multi_block.py b/tests/monad_eight/reserve_balance/test_multi_block.py index d88130c857c..455a8a59b3b 100644 --- a/tests/monad_eight/reserve_balance/test_multi_block.py +++ b/tests/monad_eight/reserve_balance/test_multi_block.py @@ -355,7 +355,7 @@ def test_credit( ) -> None: """ Test reserve balance violations for an EOA sending txs with various values, - where the exception rules are not enforced based on txs in invalid block. + but also receiving a refill of entire reserve balance in the meantime. """ # gas spend by transactions send in setup blocks prepare_tx_gas = ( @@ -461,6 +461,139 @@ def test_credit( ) +@pytest.mark.parametrize("pre_delegated", [True, False]) +@pytest.mark.parametrize("send_pos", [(0, 0), (2, 0)]) +@pytest.mark.parametrize("credit_pos", [(0, 0), (0, 1), (1, 0), (2, 1)]) +@pytest.mark.parametrize( + "send_value", + [ + pytest.param(0, id="send_zero"), + pytest.param(1, id="send_one"), + pytest.param(Spec.RESERVE_BALANCE, id="send_reserve"), + ], +) +@pytest.mark.parametrize( + "credit_value", + [ + pytest.param(0, id="credit_zero"), + pytest.param(1, id="credit_one"), + pytest.param(Spec.RESERVE_BALANCE, id="credit_reserve"), + ], +) +@pytest.mark.parametrize("credit_statically_visible", [True, False]) +def test_credit_with_value( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + pre_delegated: bool, + send_pos: Tuple[int, int], + credit_pos: Tuple[int, int], + send_value: int, + credit_value: int, + credit_statically_visible: bool, + fork: Fork, +) -> None: + """ + Test reserve balance where sender transfers value in a setup tx + and receives credit of varying amounts via direct transfer or + SELFDESTRUCT contract. + + Uses 4 blocks so send in block 0 falls outside the k=3 window, + making the emptying exception reachable for undelegated senders. + """ + prepare_tx_gas = fork.gas_costs().G_TRANSACTION + prepare_tx_fee = GAS_PRICE * prepare_tx_gas + initial_balance = Spec.RESERVE_BALANCE + send_value + prepare_tx_fee + + target_address = Address(0x1111) + if pre_delegated: + test_sender = pre.fund_eoa(initial_balance, delegation=target_address) + else: + test_sender = pre.fund_eoa(initial_balance) + + contract = Op.SSTORE(slot_code_worked, value_code_worked) + Op.STOP + contract_address = pre.deploy_contract(contract) + + nblocks = 4 + blocks = [] + test_sender_nonce = int(test_sender.nonce) + for nblock in range(nblocks): + txs = [] + for ntx in range(2): + pos = (nblock, ntx) + + if send_pos == pos: + sender = test_sender + nonce = test_sender_nonce + test_sender_nonce += 1 + else: + sender = pre.fund_eoa() + nonce = 0 + + 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, + value=send_value, + ) + txs.append(prepare_tx) + + if credit_pos == pos: + if credit_statically_visible: + credit_tx = Transaction( + gas_limit=prepare_tx_gas, + max_fee_per_gas=GAS_PRICE, + max_priority_fee_per_gas=GAS_PRICE, + to=test_sender, + value=credit_value, + sender=pre.fund_eoa(), + ) + else: + credit_contract = pre.deploy_contract( + Op.SELFDESTRUCT(address=test_sender), + balance=credit_value, + ) + credit_tx = Transaction( + gas_limit=generous_gas(fork), + max_fee_per_gas=GAS_PRICE, + max_priority_fee_per_gas=GAS_PRICE, + to=credit_contract, + sender=pre.fund_eoa(), + ) + txs.append(credit_tx) + if nblock < nblocks - 1: + blocks.append(Block(txs=txs)) + del txs + + value = 1 + 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, + ) + txs.append(test_tx) + blocks.append(Block(txs=txs)) + + # Sender balance at test time: RESERVE_BALANCE + credit_value. + violation = credit_value < value + recent_send = send_pos[0] > 0 + is_exception = not pre_delegated and not recent_send + reverted = violation and not is_exception + storage = {} if reverted else {slot_code_worked: value_code_worked} + + blockchain_test( + pre=pre, + post={contract_address: Account(storage=storage)}, + blocks=blocks, + ) + + @pytest.mark.parametrize( ["value", "balance", "violation"], [ diff --git a/tests/monad_nine/mip4_checkreservebalance/test_precompile_call.py b/tests/monad_nine/mip4_checkreservebalance/test_precompile_call.py index d41f5334e61..5d1ebdc93bd 100644 --- a/tests/monad_nine/mip4_checkreservebalance/test_precompile_call.py +++ b/tests/monad_nine/mip4_checkreservebalance/test_precompile_call.py @@ -24,6 +24,7 @@ Transaction, ) from execution_testing.forks.helpers import Fork +from execution_testing.test_types.receipt_types import TransactionReceipt from ..mip3_linear_memory.spec import Spec as SpecMIP3 from .helpers import ( @@ -655,15 +656,10 @@ def test_call_with_value( } _CHECK_ORDER_PAIRS = [ - pytest.param( - s1, - s2, - id=f"{s1.name.lower()}__{s2.name.lower()}", - ) + 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 + if CallScenario.SUCCESS not in {s1, s2} and s1.check_priority < s2.check_priority and frozenset({s1, s2}) not in _INCOMPATIBLE_SCENARIOS ] @@ -750,3 +746,162 @@ def test_check_order( }, blocks=[Block(txs=[tx])], ) + + +# --- Direct-transaction tests --- + + +def _tx_params( + *scenarios: CallScenario, + pre: Alloc, + fork: Fork, +) -> tuple[bytes, int, Address, int]: + """ + Return (calldata, value, to, gas_limit) for a set of + tx-level CallScenarios. + """ + scenario_set = set(scenarios) + if CallScenario.WRONG_SELECTOR in scenario_set: + calldata = bytes.fromhex("DEADBEEF") + else: + calldata = Spec.DIPPED_INTO_RESERVE_SELECTOR + + if CallScenario.SHORT_CALLDATA in scenario_set: + calldata = calldata[:3] + elif CallScenario.EXTRA_CALLDATA in scenario_set: + calldata = calldata + b"\xff" + + if CallScenario.NONZERO_VALUE in scenario_set: + value = 1 + else: + value = 0 + + if CallScenario.DELEGATE_TO_PRECOMPILE in scenario_set: + to: Address = pre.fund_eoa( + 0, delegation=Spec.RESERVE_BALANCE_PRECOMPILE + ) + else: + to = Spec.RESERVE_BALANCE_PRECOMPILE + + if CallScenario.LOW_GAS in scenario_set: + intrinsic_gas = fork.transaction_intrinsic_cost_calculator()( + calldata=calldata, + return_cost_deducted_prior_execution=True, + ) + gas_limit = intrinsic_gas + Spec.GAS_COST - 1 + else: + gas_limit = generous_gas(fork) + + return calldata, value, to, gas_limit + + +@pytest.mark.parametrize( + "scenario", + [s for s in CallScenario if s is not CallScenario.NOT_CALL], +) +def test_tx_revert_scenarios( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + scenario: CallScenario, +) -> None: + """ + Test precompile behavior when called directly as the transaction + `to`. + """ + gas_price = 10 + + calldata, value, to, gas_limit = _tx_params(scenario, pre=pre, fork=fork) + gas_cost = gas_limit * gas_price + sender = pre.fund_eoa(gas_cost + value) + + tx = Transaction( + gas_limit=gas_limit, + max_fee_per_gas=gas_price, + max_priority_fee_per_gas=gas_price, + to=to, + sender=sender, + data=calldata, + value=value, + expected_receipt=TransactionReceipt( + status=1 if scenario.should_succeed else 0, + ), + ) + + post: dict = { + sender: Account(balance=value), + } + + state_test( + pre=pre, + post=post, + tx=tx, + ) + + +_TX_INCOMPATIBLE_SCENARIOS = _INCOMPATIBLE_SCENARIOS | { + # EIP-7623 floor makes it impossible to create a valid tx with + # insufficient execution gas when extra calldata is appended. + # For extra calldata (5 bytes), the floor is high enough that + # we can't create a valid tx with less than 100 execution gas + # EIP-7623 floor (21200) vs (21179) - impossible + # For correct calldata (4 bytes), in the test just above it's + # EIP-7623 floor (21160) vs (21163) - possible + frozenset({CallScenario.LOW_GAS, CallScenario.EXTRA_CALLDATA}), +} + +_TX_SCENARIO_PAIRS = [ + pytest.param(s1, s2, id=f"{s1.name.lower()}__{s2.name.lower()}") + for s1 in CallScenario + for s2 in CallScenario + if CallScenario.SUCCESS not in {s1, s2} + and CallScenario.NOT_CALL not in {s1, s2} + and s1.check_priority < s2.check_priority + and frozenset({s1, s2}) not in _TX_INCOMPATIBLE_SCENARIOS +] + + +@pytest.mark.parametrize("scenario1,scenario2", _TX_SCENARIO_PAIRS) +def test_tx_revert_scenario_pairs( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + scenario1: CallScenario, + scenario2: CallScenario, +) -> None: + """ + Test when the precompile is called directly as transaction + `to` with 2 reasons to revert. + """ + gas_price = 10 + + calldata, value, to, gas_limit = _tx_params( + scenario1, scenario2, pre=pre, fork=fork + ) + gas_cost = gas_limit * gas_price + sender = pre.fund_eoa(gas_cost + value) + + tx = Transaction( + gas_limit=gas_limit, + max_fee_per_gas=gas_price, + max_priority_fee_per_gas=gas_price, + to=to, + sender=sender, + data=calldata, + value=value, + expected_receipt=TransactionReceipt( + status=0x1 + if scenario1.should_succeed and scenario2.should_succeed + else 0x0 + ), + ) + + post: dict = { + sender: Account(balance=value), + } + + state_test( + pre=pre, + post=post, + tx=tx, + )