From b8e2a823396269e0af217c672697934555ab7acc Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Thu, 26 Feb 2026 13:26:07 +0000 Subject: [PATCH 01/19] WIP: staking precompile at 0x1000 with test_precompile_call tests Co-Authored-By: Claude claude-opus-4-6 --- .../vm/precompiled_contracts/mapping.py | 3 + .../vm/precompiled_contracts/staking.py | 374 +++++++++++ .../vm/precompiled_contracts/mapping.py | 3 + .../vm/precompiled_contracts/staking.py | 374 +++++++++++ .../staking_precompile/__init__.py | 1 + .../staking_precompile/conftest.py | 1 + .../monad_eight/staking_precompile/helpers.py | 33 + tests/monad_eight/staking_precompile/spec.py | 284 +++++++++ .../test_precompile_call.py | 580 ++++++++++++++++++ 9 files changed, 1653 insertions(+) create mode 100644 src/ethereum/forks/monad_eight/vm/precompiled_contracts/staking.py create mode 100644 src/ethereum/forks/monad_nine/vm/precompiled_contracts/staking.py create mode 100644 tests/monad_eight/staking_precompile/__init__.py create mode 100644 tests/monad_eight/staking_precompile/conftest.py create mode 100644 tests/monad_eight/staking_precompile/helpers.py create mode 100644 tests/monad_eight/staking_precompile/spec.py create mode 100644 tests/monad_eight/staking_precompile/test_precompile_call.py diff --git a/src/ethereum/forks/monad_eight/vm/precompiled_contracts/mapping.py b/src/ethereum/forks/monad_eight/vm/precompiled_contracts/mapping.py index 7486203c3e6..ae5faafe90a 100644 --- a/src/ethereum/forks/monad_eight/vm/precompiled_contracts/mapping.py +++ b/src/ethereum/forks/monad_eight/vm/precompiled_contracts/mapping.py @@ -33,6 +33,7 @@ POINT_EVALUATION_ADDRESS, RIPEMD160_ADDRESS, SHA256_ADDRESS, + STAKING_ADDRESS, ) from .alt_bn128 import alt_bn128_add, alt_bn128_mul, alt_bn128_pairing_check from .blake2f import blake2f @@ -54,6 +55,7 @@ from .point_evaluation import point_evaluation from .ripemd160 import ripemd160 from .sha256 import sha256 +from .staking import staking PRE_COMPILED_CONTRACTS: Dict[Address, Callable] = { ECRECOVER_ADDRESS: ecrecover, @@ -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, + STAKING_ADDRESS: staking, } diff --git a/src/ethereum/forks/monad_eight/vm/precompiled_contracts/staking.py b/src/ethereum/forks/monad_eight/vm/precompiled_contracts/staking.py new file mode 100644 index 00000000000..b4ca046e696 --- /dev/null +++ b/src/ethereum/forks/monad_eight/vm/precompiled_contracts/staking.py @@ -0,0 +1,374 @@ +""" +Ethereum Virtual Machine (EVM) STAKING PRECOMPILED CONTRACT. + +.. contents:: Table of Contents + :backlinks: none + :local: + +Introduction +------------ + +Implementation of the staking precompiled contract. +Getter functions return constant stub values. +Setter functions are stubs that respect interface rules. +""" + +from ethereum_types.bytes import Bytes +from ethereum_types.numeric import U256, Uint + +from ...vm import Evm +from ...vm.exceptions import InvalidParameter +from ...vm.gas import charge_gas + +# Gas costs per function (from staking spec) +GAS_ADD_VALIDATOR = Uint(505125) +GAS_DELEGATE = Uint(260850) +GAS_UNDELEGATE = Uint(147750) +GAS_WITHDRAW = Uint(68675) +GAS_COMPOUND = Uint(285050) +GAS_CLAIM_REWARDS = Uint(155375) +GAS_CHANGE_COMMISSION = Uint(39475) +GAS_EXTERNAL_REWARD = Uint(62300) +GAS_GET_VALIDATOR = Uint(97200) +GAS_GET_DELEGATOR = Uint(184900) +GAS_GET_WITHDRAWAL_REQUEST = Uint(24300) +GAS_GET_CONSENSUS_VALIDATOR_SET = Uint(814000) +GAS_GET_SNAPSHOT_VALIDATOR_SET = Uint(814000) +GAS_GET_EXECUTION_VALIDATOR_SET = Uint(814000) +GAS_GET_DELEGATIONS = Uint(814000) +GAS_GET_DELEGATORS = Uint(814000) +GAS_GET_EPOCH = Uint(16200) +GAS_GET_PROPOSER_VAL_ID = Uint(100) +GAS_UNKNOWN_SELECTOR = Uint(40000) + +# Function selectors +SELECTOR_ADD_VALIDATOR = bytes.fromhex("f145204c") +SELECTOR_DELEGATE = bytes.fromhex("84994fec") +SELECTOR_UNDELEGATE = bytes.fromhex("5cf41514") +SELECTOR_WITHDRAW = bytes.fromhex("aed2ee73") +SELECTOR_COMPOUND = bytes.fromhex("b34fea67") +SELECTOR_CLAIM_REWARDS = bytes.fromhex("a76e2ca5") +SELECTOR_CHANGE_COMMISSION = bytes.fromhex("9bdcc3c8") +SELECTOR_EXTERNAL_REWARD = bytes.fromhex("e4b3303b") +SELECTOR_GET_VALIDATOR = bytes.fromhex("2b6d639a") +SELECTOR_GET_DELEGATOR = bytes.fromhex("573c1ce0") +SELECTOR_GET_WITHDRAWAL_REQUEST = bytes.fromhex("56fa2045") +SELECTOR_GET_CONSENSUS_VALIDATOR_SET = bytes.fromhex("fb29b729") +SELECTOR_GET_SNAPSHOT_VALIDATOR_SET = bytes.fromhex("de66a368") +SELECTOR_GET_EXECUTION_VALIDATOR_SET = bytes.fromhex("7cb074df") +SELECTOR_GET_DELEGATIONS = bytes.fromhex("4fd66050") +SELECTOR_GET_DELEGATORS = bytes.fromhex("a0843a26") +SELECTOR_GET_EPOCH = bytes.fromhex("757991a8") +SELECTOR_GET_PROPOSER_VAL_ID = bytes.fromhex("fbacb0be") + +# Syscall selectors (system transactions only) +SELECTOR_SYSCALL_ON_EPOCH_CHANGE = bytes.fromhex("1d4e9f02") +SELECTOR_SYSCALL_REWARD = bytes.fromhex("791bdcf3") +SELECTOR_SYSCALL_SNAPSHOT = bytes.fromhex("157eeb21") + +# All known selectors mapped to their gas cost and whether they are +# payable (accept msg.value > 0) +_SELECTOR_INFO: dict[bytes, tuple[Uint, bool, int]] = { + # (gas_cost, is_payable, expected_data_size) + # Setters + # addValidator(bytes,bytes,bytes) - 4+32*3 offset words + SELECTOR_ADD_VALIDATOR: (GAS_ADD_VALIDATOR, True, 100), + # delegate(uint64) - 4+32 + SELECTOR_DELEGATE: (GAS_DELEGATE, True, 36), + # undelegate(uint64,uint256,uint8) - 4+32*3 + SELECTOR_UNDELEGATE: (GAS_UNDELEGATE, False, 100), + # withdraw(uint64,uint8) - 4+32*2 + SELECTOR_WITHDRAW: (GAS_WITHDRAW, False, 68), + # compound(uint64) - 4+32 + SELECTOR_COMPOUND: (GAS_COMPOUND, False, 36), + # claimRewards(uint64) - 4+32 + SELECTOR_CLAIM_REWARDS: (GAS_CLAIM_REWARDS, False, 36), + # changeCommission(uint64,uint256) - 4+32*2 + SELECTOR_CHANGE_COMMISSION: (GAS_CHANGE_COMMISSION, False, 68), + # externalReward(uint64) - 4+32 + SELECTOR_EXTERNAL_REWARD: (GAS_EXTERNAL_REWARD, True, 36), + # Getters + # getValidator(uint64) - 4+32 + SELECTOR_GET_VALIDATOR: (GAS_GET_VALIDATOR, False, 36), + # getDelegator(uint64,address) - 4+32*2 + SELECTOR_GET_DELEGATOR: (GAS_GET_DELEGATOR, False, 68), + # getWithdrawalRequest(uint64,address,uint8) - 4+32*3 + SELECTOR_GET_WITHDRAWAL_REQUEST: ( + GAS_GET_WITHDRAWAL_REQUEST, + False, + 100, + ), + # getConsensusValidatorSet(uint32) - 4+32 + SELECTOR_GET_CONSENSUS_VALIDATOR_SET: ( + GAS_GET_CONSENSUS_VALIDATOR_SET, + False, + 36, + ), + # getSnapshotValidatorSet(uint32) - 4+32 + SELECTOR_GET_SNAPSHOT_VALIDATOR_SET: ( + GAS_GET_SNAPSHOT_VALIDATOR_SET, + False, + 36, + ), + # getExecutionValidatorSet(uint32) - 4+32 + SELECTOR_GET_EXECUTION_VALIDATOR_SET: ( + GAS_GET_EXECUTION_VALIDATOR_SET, + False, + 36, + ), + # getDelegations(address,uint64) - 4+32*2 + SELECTOR_GET_DELEGATIONS: (GAS_GET_DELEGATIONS, False, 68), + # getDelegators(uint64,address) - 4+32*2 + SELECTOR_GET_DELEGATORS: (GAS_GET_DELEGATORS, False, 68), + # getEpoch() - 4 + SELECTOR_GET_EPOCH: (GAS_GET_EPOCH, False, 4), + # getProposerValId() - 4 + SELECTOR_GET_PROPOSER_VAL_ID: (GAS_GET_PROPOSER_VAL_ID, False, 4), + # Syscalls + SELECTOR_SYSCALL_ON_EPOCH_CHANGE: (GAS_UNKNOWN_SELECTOR, False, 36), + SELECTOR_SYSCALL_REWARD: (GAS_UNKNOWN_SELECTOR, False, 36), + SELECTOR_SYSCALL_SNAPSHOT: (GAS_UNKNOWN_SELECTOR, False, 4), +} + +# Sets of selectors by category +_SETTER_SELECTORS = frozenset( + { + SELECTOR_ADD_VALIDATOR, + SELECTOR_DELEGATE, + SELECTOR_UNDELEGATE, + SELECTOR_WITHDRAW, + SELECTOR_COMPOUND, + SELECTOR_CLAIM_REWARDS, + SELECTOR_CHANGE_COMMISSION, + SELECTOR_EXTERNAL_REWARD, + } +) + +_GETTER_SELECTORS = frozenset( + { + SELECTOR_GET_VALIDATOR, + SELECTOR_GET_DELEGATOR, + SELECTOR_GET_WITHDRAWAL_REQUEST, + SELECTOR_GET_CONSENSUS_VALIDATOR_SET, + SELECTOR_GET_SNAPSHOT_VALIDATOR_SET, + SELECTOR_GET_EXECUTION_VALIDATOR_SET, + SELECTOR_GET_DELEGATIONS, + SELECTOR_GET_DELEGATORS, + SELECTOR_GET_EPOCH, + SELECTOR_GET_PROPOSER_VAL_ID, + } +) + +_SYSCALL_SELECTORS = frozenset( + { + SELECTOR_SYSCALL_ON_EPOCH_CHANGE, + SELECTOR_SYSCALL_REWARD, + SELECTOR_SYSCALL_SNAPSHOT, + } +) + + +def _validate_call_type(evm: Evm) -> None: + """ + Validate that the precompile is invoked via CALL only. + + STATICCALL, DELEGATECALL, and CALLCODE are not allowed. + """ + if evm.message.is_static: + raise InvalidParameter + if not evm.message.should_transfer_value: + raise InvalidParameter + if evm.message.code_address != evm.message.current_target: + raise InvalidParameter + + +def _abi_encode_uint256(value: int) -> bytes: + """Encode an integer as a 32-byte big-endian uint256.""" + return U256(value).to_be_bytes32() + + +def _abi_encode_bool(value: bool) -> bytes: + """Encode a boolean as a 32-byte big-endian uint256.""" + return _abi_encode_uint256(1 if value else 0) + + +def _handle_get_epoch(evm: Evm) -> None: + """ + Handle getEpoch() call. + + Return stub: epoch=1, in_boundary_delay=false. + """ + # Returns (uint64 epoch, bool inBoundaryDelay) + evm.output = _abi_encode_uint256(1) + _abi_encode_bool(False) + + +def _handle_get_proposer_val_id(evm: Evm) -> None: + """ + Handle getProposerValId() call. + + Return stub: validator_id=1. + """ + # Returns uint64 + evm.output = _abi_encode_uint256(1) + + +def _handle_get_validator(evm: Evm) -> None: + """ + Handle getValidator(uint64) call. + + Return stub: a zeroed-out validator structure. + """ + # Return 0 for all fields (empty validator) + # The struct has many fields; return enough zero words + evm.output = b"\x00" * 32 * 20 + + +def _handle_get_delegator(evm: Evm) -> None: + """ + Handle getDelegator(uint64,address) call. + + Return stub: a zeroed-out delegator structure. + """ + evm.output = b"\x00" * 32 * 10 + + +def _handle_get_withdrawal_request(evm: Evm) -> None: + """ + Handle getWithdrawalRequest(uint64,address,uint8) call. + + Return stub: a zeroed-out withdrawal request. + """ + # Returns (uint256 amount, uint256 accumulator, uint64 activationEpoch) + evm.output = ( + _abi_encode_uint256(0) + + _abi_encode_uint256(0) + + _abi_encode_uint256(0) + ) + + +def _handle_get_validator_set(evm: Evm) -> None: + """ + Handle validator set query (consensus/snapshot/execution). + + Return stub: empty set with done=true, nextCursor=0. + """ + # Returns (bool done, uint32 nextCursor, bytes data) + # Use ABI encoding with dynamic bytes + # offset for bytes field = 96 (3 words) + evm.output = ( + _abi_encode_bool(True) # done + + _abi_encode_uint256(0) # nextCursor + + _abi_encode_uint256(96) # offset to bytes + + _abi_encode_uint256(0) # bytes length = 0 + ) + + +def _handle_get_delegations(evm: Evm) -> None: + """ + Handle getDelegations(address,uint64) call. + + Return stub: empty delegation list. + """ + # Returns (bool done, uint64 nextCursor, bytes data) + evm.output = ( + _abi_encode_bool(True) + + _abi_encode_uint256(0) + + _abi_encode_uint256(96) + + _abi_encode_uint256(0) + ) + + +def _handle_get_delegators(evm: Evm) -> None: + """ + Handle getDelegators(uint64,address) call. + + Return stub: empty delegator list. + """ + evm.output = ( + _abi_encode_bool(True) + + _abi_encode_uint256(0) + + _abi_encode_uint256(96) + + _abi_encode_uint256(0) + ) + + +# Map getter selectors to their handler functions +_GETTER_HANDLERS: dict[bytes, object] = { + SELECTOR_GET_EPOCH: _handle_get_epoch, + SELECTOR_GET_PROPOSER_VAL_ID: _handle_get_proposer_val_id, + SELECTOR_GET_VALIDATOR: _handle_get_validator, + SELECTOR_GET_DELEGATOR: _handle_get_delegator, + SELECTOR_GET_WITHDRAWAL_REQUEST: _handle_get_withdrawal_request, + SELECTOR_GET_CONSENSUS_VALIDATOR_SET: _handle_get_validator_set, + SELECTOR_GET_SNAPSHOT_VALIDATOR_SET: _handle_get_validator_set, + SELECTOR_GET_EXECUTION_VALIDATOR_SET: _handle_get_validator_set, + SELECTOR_GET_DELEGATIONS: _handle_get_delegations, + SELECTOR_GET_DELEGATORS: _handle_get_delegators, +} + + +def staking(evm: Evm) -> None: + """ + Implement the staking precompiled contract. + + The precompile must be invoked via CALL. Invocations via STATICCALL, + DELEGATECALL, or CALLCODE must revert. + + Calldata must begin with a 4-byte function selector. Unknown selectors + and malformed calldata cause a revert that consumes all provided gas. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + data = evm.message.data + + # Must have at least 4 bytes for the selector + if len(data) < 4: + charge_gas(evm, GAS_UNKNOWN_SELECTOR) + raise InvalidParameter + + selector = bytes(data[:4]) + + # Look up selector info + info = _SELECTOR_INFO.get(selector) + if info is None: + charge_gas(evm, GAS_UNKNOWN_SELECTOR) + raise InvalidParameter + + gas_cost, is_payable, expected_size = info + + # GAS + charge_gas(evm, gas_cost) + + # Must be invoked via CALL only + _validate_call_type(evm) + + # Non-payable functions reject nonzero value + if not is_payable and evm.message.value != 0: + raise InvalidParameter + + # Validate calldata size + if len(data) != expected_size: + raise InvalidParameter + + # Dispatch + if selector in _GETTER_SELECTORS: + handler = _GETTER_HANDLERS[selector] + handler(evm) # type: ignore[operator] + elif selector in _SETTER_SELECTORS: + # Setter stubs: validate interface rules but do nothing + # Return empty output (setters don't return data except + # addValidator which returns a uint64) + if selector == SELECTOR_ADD_VALIDATOR: + # addValidator returns the new validator ID + evm.output = _abi_encode_uint256(1) + else: + evm.output = Bytes(b"") + elif selector in _SYSCALL_SELECTORS: + # Syscall stubs: reject non-system calls + raise InvalidParameter + else: + raise InvalidParameter 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 e4253b8970a..ab0437984dc 100644 --- a/src/ethereum/forks/monad_nine/vm/precompiled_contracts/mapping.py +++ b/src/ethereum/forks/monad_nine/vm/precompiled_contracts/mapping.py @@ -34,6 +34,7 @@ RESERVE_BALANCE_ADDRESS, RIPEMD160_ADDRESS, SHA256_ADDRESS, + STAKING_ADDRESS, ) from .alt_bn128 import alt_bn128_add, alt_bn128_mul, alt_bn128_pairing_check from .blake2f import blake2f @@ -56,6 +57,7 @@ from .reserve_balance import reserve_balance from .ripemd160 import ripemd160 from .sha256 import sha256 +from .staking import staking PRE_COMPILED_CONTRACTS: Dict[Address, Callable] = { ECRECOVER_ADDRESS: ecrecover, @@ -76,5 +78,6 @@ 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, + STAKING_ADDRESS: staking, RESERVE_BALANCE_ADDRESS: reserve_balance, } diff --git a/src/ethereum/forks/monad_nine/vm/precompiled_contracts/staking.py b/src/ethereum/forks/monad_nine/vm/precompiled_contracts/staking.py new file mode 100644 index 00000000000..b4ca046e696 --- /dev/null +++ b/src/ethereum/forks/monad_nine/vm/precompiled_contracts/staking.py @@ -0,0 +1,374 @@ +""" +Ethereum Virtual Machine (EVM) STAKING PRECOMPILED CONTRACT. + +.. contents:: Table of Contents + :backlinks: none + :local: + +Introduction +------------ + +Implementation of the staking precompiled contract. +Getter functions return constant stub values. +Setter functions are stubs that respect interface rules. +""" + +from ethereum_types.bytes import Bytes +from ethereum_types.numeric import U256, Uint + +from ...vm import Evm +from ...vm.exceptions import InvalidParameter +from ...vm.gas import charge_gas + +# Gas costs per function (from staking spec) +GAS_ADD_VALIDATOR = Uint(505125) +GAS_DELEGATE = Uint(260850) +GAS_UNDELEGATE = Uint(147750) +GAS_WITHDRAW = Uint(68675) +GAS_COMPOUND = Uint(285050) +GAS_CLAIM_REWARDS = Uint(155375) +GAS_CHANGE_COMMISSION = Uint(39475) +GAS_EXTERNAL_REWARD = Uint(62300) +GAS_GET_VALIDATOR = Uint(97200) +GAS_GET_DELEGATOR = Uint(184900) +GAS_GET_WITHDRAWAL_REQUEST = Uint(24300) +GAS_GET_CONSENSUS_VALIDATOR_SET = Uint(814000) +GAS_GET_SNAPSHOT_VALIDATOR_SET = Uint(814000) +GAS_GET_EXECUTION_VALIDATOR_SET = Uint(814000) +GAS_GET_DELEGATIONS = Uint(814000) +GAS_GET_DELEGATORS = Uint(814000) +GAS_GET_EPOCH = Uint(16200) +GAS_GET_PROPOSER_VAL_ID = Uint(100) +GAS_UNKNOWN_SELECTOR = Uint(40000) + +# Function selectors +SELECTOR_ADD_VALIDATOR = bytes.fromhex("f145204c") +SELECTOR_DELEGATE = bytes.fromhex("84994fec") +SELECTOR_UNDELEGATE = bytes.fromhex("5cf41514") +SELECTOR_WITHDRAW = bytes.fromhex("aed2ee73") +SELECTOR_COMPOUND = bytes.fromhex("b34fea67") +SELECTOR_CLAIM_REWARDS = bytes.fromhex("a76e2ca5") +SELECTOR_CHANGE_COMMISSION = bytes.fromhex("9bdcc3c8") +SELECTOR_EXTERNAL_REWARD = bytes.fromhex("e4b3303b") +SELECTOR_GET_VALIDATOR = bytes.fromhex("2b6d639a") +SELECTOR_GET_DELEGATOR = bytes.fromhex("573c1ce0") +SELECTOR_GET_WITHDRAWAL_REQUEST = bytes.fromhex("56fa2045") +SELECTOR_GET_CONSENSUS_VALIDATOR_SET = bytes.fromhex("fb29b729") +SELECTOR_GET_SNAPSHOT_VALIDATOR_SET = bytes.fromhex("de66a368") +SELECTOR_GET_EXECUTION_VALIDATOR_SET = bytes.fromhex("7cb074df") +SELECTOR_GET_DELEGATIONS = bytes.fromhex("4fd66050") +SELECTOR_GET_DELEGATORS = bytes.fromhex("a0843a26") +SELECTOR_GET_EPOCH = bytes.fromhex("757991a8") +SELECTOR_GET_PROPOSER_VAL_ID = bytes.fromhex("fbacb0be") + +# Syscall selectors (system transactions only) +SELECTOR_SYSCALL_ON_EPOCH_CHANGE = bytes.fromhex("1d4e9f02") +SELECTOR_SYSCALL_REWARD = bytes.fromhex("791bdcf3") +SELECTOR_SYSCALL_SNAPSHOT = bytes.fromhex("157eeb21") + +# All known selectors mapped to their gas cost and whether they are +# payable (accept msg.value > 0) +_SELECTOR_INFO: dict[bytes, tuple[Uint, bool, int]] = { + # (gas_cost, is_payable, expected_data_size) + # Setters + # addValidator(bytes,bytes,bytes) - 4+32*3 offset words + SELECTOR_ADD_VALIDATOR: (GAS_ADD_VALIDATOR, True, 100), + # delegate(uint64) - 4+32 + SELECTOR_DELEGATE: (GAS_DELEGATE, True, 36), + # undelegate(uint64,uint256,uint8) - 4+32*3 + SELECTOR_UNDELEGATE: (GAS_UNDELEGATE, False, 100), + # withdraw(uint64,uint8) - 4+32*2 + SELECTOR_WITHDRAW: (GAS_WITHDRAW, False, 68), + # compound(uint64) - 4+32 + SELECTOR_COMPOUND: (GAS_COMPOUND, False, 36), + # claimRewards(uint64) - 4+32 + SELECTOR_CLAIM_REWARDS: (GAS_CLAIM_REWARDS, False, 36), + # changeCommission(uint64,uint256) - 4+32*2 + SELECTOR_CHANGE_COMMISSION: (GAS_CHANGE_COMMISSION, False, 68), + # externalReward(uint64) - 4+32 + SELECTOR_EXTERNAL_REWARD: (GAS_EXTERNAL_REWARD, True, 36), + # Getters + # getValidator(uint64) - 4+32 + SELECTOR_GET_VALIDATOR: (GAS_GET_VALIDATOR, False, 36), + # getDelegator(uint64,address) - 4+32*2 + SELECTOR_GET_DELEGATOR: (GAS_GET_DELEGATOR, False, 68), + # getWithdrawalRequest(uint64,address,uint8) - 4+32*3 + SELECTOR_GET_WITHDRAWAL_REQUEST: ( + GAS_GET_WITHDRAWAL_REQUEST, + False, + 100, + ), + # getConsensusValidatorSet(uint32) - 4+32 + SELECTOR_GET_CONSENSUS_VALIDATOR_SET: ( + GAS_GET_CONSENSUS_VALIDATOR_SET, + False, + 36, + ), + # getSnapshotValidatorSet(uint32) - 4+32 + SELECTOR_GET_SNAPSHOT_VALIDATOR_SET: ( + GAS_GET_SNAPSHOT_VALIDATOR_SET, + False, + 36, + ), + # getExecutionValidatorSet(uint32) - 4+32 + SELECTOR_GET_EXECUTION_VALIDATOR_SET: ( + GAS_GET_EXECUTION_VALIDATOR_SET, + False, + 36, + ), + # getDelegations(address,uint64) - 4+32*2 + SELECTOR_GET_DELEGATIONS: (GAS_GET_DELEGATIONS, False, 68), + # getDelegators(uint64,address) - 4+32*2 + SELECTOR_GET_DELEGATORS: (GAS_GET_DELEGATORS, False, 68), + # getEpoch() - 4 + SELECTOR_GET_EPOCH: (GAS_GET_EPOCH, False, 4), + # getProposerValId() - 4 + SELECTOR_GET_PROPOSER_VAL_ID: (GAS_GET_PROPOSER_VAL_ID, False, 4), + # Syscalls + SELECTOR_SYSCALL_ON_EPOCH_CHANGE: (GAS_UNKNOWN_SELECTOR, False, 36), + SELECTOR_SYSCALL_REWARD: (GAS_UNKNOWN_SELECTOR, False, 36), + SELECTOR_SYSCALL_SNAPSHOT: (GAS_UNKNOWN_SELECTOR, False, 4), +} + +# Sets of selectors by category +_SETTER_SELECTORS = frozenset( + { + SELECTOR_ADD_VALIDATOR, + SELECTOR_DELEGATE, + SELECTOR_UNDELEGATE, + SELECTOR_WITHDRAW, + SELECTOR_COMPOUND, + SELECTOR_CLAIM_REWARDS, + SELECTOR_CHANGE_COMMISSION, + SELECTOR_EXTERNAL_REWARD, + } +) + +_GETTER_SELECTORS = frozenset( + { + SELECTOR_GET_VALIDATOR, + SELECTOR_GET_DELEGATOR, + SELECTOR_GET_WITHDRAWAL_REQUEST, + SELECTOR_GET_CONSENSUS_VALIDATOR_SET, + SELECTOR_GET_SNAPSHOT_VALIDATOR_SET, + SELECTOR_GET_EXECUTION_VALIDATOR_SET, + SELECTOR_GET_DELEGATIONS, + SELECTOR_GET_DELEGATORS, + SELECTOR_GET_EPOCH, + SELECTOR_GET_PROPOSER_VAL_ID, + } +) + +_SYSCALL_SELECTORS = frozenset( + { + SELECTOR_SYSCALL_ON_EPOCH_CHANGE, + SELECTOR_SYSCALL_REWARD, + SELECTOR_SYSCALL_SNAPSHOT, + } +) + + +def _validate_call_type(evm: Evm) -> None: + """ + Validate that the precompile is invoked via CALL only. + + STATICCALL, DELEGATECALL, and CALLCODE are not allowed. + """ + if evm.message.is_static: + raise InvalidParameter + if not evm.message.should_transfer_value: + raise InvalidParameter + if evm.message.code_address != evm.message.current_target: + raise InvalidParameter + + +def _abi_encode_uint256(value: int) -> bytes: + """Encode an integer as a 32-byte big-endian uint256.""" + return U256(value).to_be_bytes32() + + +def _abi_encode_bool(value: bool) -> bytes: + """Encode a boolean as a 32-byte big-endian uint256.""" + return _abi_encode_uint256(1 if value else 0) + + +def _handle_get_epoch(evm: Evm) -> None: + """ + Handle getEpoch() call. + + Return stub: epoch=1, in_boundary_delay=false. + """ + # Returns (uint64 epoch, bool inBoundaryDelay) + evm.output = _abi_encode_uint256(1) + _abi_encode_bool(False) + + +def _handle_get_proposer_val_id(evm: Evm) -> None: + """ + Handle getProposerValId() call. + + Return stub: validator_id=1. + """ + # Returns uint64 + evm.output = _abi_encode_uint256(1) + + +def _handle_get_validator(evm: Evm) -> None: + """ + Handle getValidator(uint64) call. + + Return stub: a zeroed-out validator structure. + """ + # Return 0 for all fields (empty validator) + # The struct has many fields; return enough zero words + evm.output = b"\x00" * 32 * 20 + + +def _handle_get_delegator(evm: Evm) -> None: + """ + Handle getDelegator(uint64,address) call. + + Return stub: a zeroed-out delegator structure. + """ + evm.output = b"\x00" * 32 * 10 + + +def _handle_get_withdrawal_request(evm: Evm) -> None: + """ + Handle getWithdrawalRequest(uint64,address,uint8) call. + + Return stub: a zeroed-out withdrawal request. + """ + # Returns (uint256 amount, uint256 accumulator, uint64 activationEpoch) + evm.output = ( + _abi_encode_uint256(0) + + _abi_encode_uint256(0) + + _abi_encode_uint256(0) + ) + + +def _handle_get_validator_set(evm: Evm) -> None: + """ + Handle validator set query (consensus/snapshot/execution). + + Return stub: empty set with done=true, nextCursor=0. + """ + # Returns (bool done, uint32 nextCursor, bytes data) + # Use ABI encoding with dynamic bytes + # offset for bytes field = 96 (3 words) + evm.output = ( + _abi_encode_bool(True) # done + + _abi_encode_uint256(0) # nextCursor + + _abi_encode_uint256(96) # offset to bytes + + _abi_encode_uint256(0) # bytes length = 0 + ) + + +def _handle_get_delegations(evm: Evm) -> None: + """ + Handle getDelegations(address,uint64) call. + + Return stub: empty delegation list. + """ + # Returns (bool done, uint64 nextCursor, bytes data) + evm.output = ( + _abi_encode_bool(True) + + _abi_encode_uint256(0) + + _abi_encode_uint256(96) + + _abi_encode_uint256(0) + ) + + +def _handle_get_delegators(evm: Evm) -> None: + """ + Handle getDelegators(uint64,address) call. + + Return stub: empty delegator list. + """ + evm.output = ( + _abi_encode_bool(True) + + _abi_encode_uint256(0) + + _abi_encode_uint256(96) + + _abi_encode_uint256(0) + ) + + +# Map getter selectors to their handler functions +_GETTER_HANDLERS: dict[bytes, object] = { + SELECTOR_GET_EPOCH: _handle_get_epoch, + SELECTOR_GET_PROPOSER_VAL_ID: _handle_get_proposer_val_id, + SELECTOR_GET_VALIDATOR: _handle_get_validator, + SELECTOR_GET_DELEGATOR: _handle_get_delegator, + SELECTOR_GET_WITHDRAWAL_REQUEST: _handle_get_withdrawal_request, + SELECTOR_GET_CONSENSUS_VALIDATOR_SET: _handle_get_validator_set, + SELECTOR_GET_SNAPSHOT_VALIDATOR_SET: _handle_get_validator_set, + SELECTOR_GET_EXECUTION_VALIDATOR_SET: _handle_get_validator_set, + SELECTOR_GET_DELEGATIONS: _handle_get_delegations, + SELECTOR_GET_DELEGATORS: _handle_get_delegators, +} + + +def staking(evm: Evm) -> None: + """ + Implement the staking precompiled contract. + + The precompile must be invoked via CALL. Invocations via STATICCALL, + DELEGATECALL, or CALLCODE must revert. + + Calldata must begin with a 4-byte function selector. Unknown selectors + and malformed calldata cause a revert that consumes all provided gas. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + data = evm.message.data + + # Must have at least 4 bytes for the selector + if len(data) < 4: + charge_gas(evm, GAS_UNKNOWN_SELECTOR) + raise InvalidParameter + + selector = bytes(data[:4]) + + # Look up selector info + info = _SELECTOR_INFO.get(selector) + if info is None: + charge_gas(evm, GAS_UNKNOWN_SELECTOR) + raise InvalidParameter + + gas_cost, is_payable, expected_size = info + + # GAS + charge_gas(evm, gas_cost) + + # Must be invoked via CALL only + _validate_call_type(evm) + + # Non-payable functions reject nonzero value + if not is_payable and evm.message.value != 0: + raise InvalidParameter + + # Validate calldata size + if len(data) != expected_size: + raise InvalidParameter + + # Dispatch + if selector in _GETTER_SELECTORS: + handler = _GETTER_HANDLERS[selector] + handler(evm) # type: ignore[operator] + elif selector in _SETTER_SELECTORS: + # Setter stubs: validate interface rules but do nothing + # Return empty output (setters don't return data except + # addValidator which returns a uint64) + if selector == SELECTOR_ADD_VALIDATOR: + # addValidator returns the new validator ID + evm.output = _abi_encode_uint256(1) + else: + evm.output = Bytes(b"") + elif selector in _SYSCALL_SELECTORS: + # Syscall stubs: reject non-system calls + raise InvalidParameter + else: + raise InvalidParameter diff --git a/tests/monad_eight/staking_precompile/__init__.py b/tests/monad_eight/staking_precompile/__init__.py new file mode 100644 index 00000000000..b06a62de30b --- /dev/null +++ b/tests/monad_eight/staking_precompile/__init__.py @@ -0,0 +1 @@ +"""Tests for staking precompile.""" diff --git a/tests/monad_eight/staking_precompile/conftest.py b/tests/monad_eight/staking_precompile/conftest.py new file mode 100644 index 00000000000..ff2f007aadf --- /dev/null +++ b/tests/monad_eight/staking_precompile/conftest.py @@ -0,0 +1 @@ +"""Pytest configuration for staking precompile tests.""" diff --git a/tests/monad_eight/staking_precompile/helpers.py b/tests/monad_eight/staking_precompile/helpers.py new file mode 100644 index 00000000000..a599c30fbf0 --- /dev/null +++ b/tests/monad_eight/staking_precompile/helpers.py @@ -0,0 +1,33 @@ +"""Helper functions for staking precompile tests.""" + +from execution_testing import Bytecode, Op +from execution_testing.forks.helpers import Fork + + +def generous_gas(fork: Fork) -> int: + """Return generous parametrized gas to always be enough.""" + constant = 1_000_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 + return constant + 5 * sstore_cost + deploy_cost + 5 * access_cost + + +def build_calldata(selector: int, calldata_size: int) -> Bytecode: + """ + Build bytecode that stores a selector and padding in memory. + + Place the 4-byte selector at mem[28:32] and fill mem[32:..] with + zero-padded ABI words so that the total args region is calldata_size + bytes. + """ + code = Op.PUSH4(selector) + Op.PUSH1(0) + Op.MSTORE + # If calldata_size > 4, we need additional words in memory + extra = calldata_size - 4 + if extra > 0: + words = (extra + 31) // 32 + for i in range(words): + # Store a dummy uint256 value (1) for each ABI param word + code += Op.MSTORE(32 + i * 32, 1) + return code diff --git a/tests/monad_eight/staking_precompile/spec.py b/tests/monad_eight/staking_precompile/spec.py new file mode 100644 index 00000000000..d457f8e0bc2 --- /dev/null +++ b/tests/monad_eight/staking_precompile/spec.py @@ -0,0 +1,284 @@ +"""Define staking precompile specification constants.""" + +from dataclasses import dataclass + +from execution_testing import Address + + +@dataclass(frozen=True) +class ReferenceSpec: + """Define the reference spec version and git path.""" + + git_path: str + version: str + + +ref_spec_staking = ReferenceSpec("staking/staking-precompile", "main") + +# Precompile address for staking +STAKING_PRECOMPILE = Address(0x1000) + +# Function selectors - Setters +SELECTOR_ADD_VALIDATOR = 0xF145204C +SELECTOR_DELEGATE = 0x84994FEC +SELECTOR_UNDELEGATE = 0x5CF41514 +SELECTOR_WITHDRAW = 0xAED2EE73 +SELECTOR_COMPOUND = 0xB34FEA67 +SELECTOR_CLAIM_REWARDS = 0xA76E2CA5 +SELECTOR_CHANGE_COMMISSION = 0x9BDCC3C8 +SELECTOR_EXTERNAL_REWARD = 0xE4B3303B + +# Function selectors - Getters +SELECTOR_GET_VALIDATOR = 0x2B6D639A +SELECTOR_GET_DELEGATOR = 0x573C1CE0 +SELECTOR_GET_WITHDRAWAL_REQUEST = 0x56FA2045 +SELECTOR_GET_CONSENSUS_VALIDATOR_SET = 0xFB29B729 +SELECTOR_GET_SNAPSHOT_VALIDATOR_SET = 0xDE66A368 +SELECTOR_GET_EXECUTION_VALIDATOR_SET = 0x7CB074DF +SELECTOR_GET_DELEGATIONS = 0x4FD66050 +SELECTOR_GET_DELEGATORS = 0xA0843A26 +SELECTOR_GET_EPOCH = 0x757991A8 +SELECTOR_GET_PROPOSER_VAL_ID = 0xFBACB0BE + +# Function selectors - Syscalls +SELECTOR_SYSCALL_ON_EPOCH_CHANGE = 0x1D4E9F02 +SELECTOR_SYSCALL_REWARD = 0x791BDCF3 +SELECTOR_SYSCALL_SNAPSHOT = 0x157EEB21 + +# Gas costs per function +GAS_ADD_VALIDATOR = 505125 +GAS_DELEGATE = 260850 +GAS_UNDELEGATE = 147750 +GAS_WITHDRAW = 68675 +GAS_COMPOUND = 285050 +GAS_CLAIM_REWARDS = 155375 +GAS_CHANGE_COMMISSION = 39475 +GAS_EXTERNAL_REWARD = 62300 +GAS_GET_VALIDATOR = 97200 +GAS_GET_DELEGATOR = 184900 +GAS_GET_WITHDRAWAL_REQUEST = 24300 +GAS_GET_CONSENSUS_VALIDATOR_SET = 814000 +GAS_GET_SNAPSHOT_VALIDATOR_SET = 814000 +GAS_GET_EXECUTION_VALIDATOR_SET = 814000 +GAS_GET_DELEGATIONS = 814000 +GAS_GET_DELEGATORS = 814000 +GAS_GET_EPOCH = 16200 +GAS_GET_PROPOSER_VAL_ID = 100 +GAS_UNKNOWN_SELECTOR = 40000 + +# Expected calldata sizes (selector + ABI-encoded params) +CALLDATA_SIZE_ADD_VALIDATOR = 100 # 4 + 32*3 (three offset words) +CALLDATA_SIZE_DELEGATE = 36 # 4 + 32 (uint64) +CALLDATA_SIZE_UNDELEGATE = 100 # 4 + 32*3 +CALLDATA_SIZE_WITHDRAW = 68 # 4 + 32*2 +CALLDATA_SIZE_COMPOUND = 36 +CALLDATA_SIZE_CLAIM_REWARDS = 36 +CALLDATA_SIZE_CHANGE_COMMISSION = 68 +CALLDATA_SIZE_EXTERNAL_REWARD = 36 +CALLDATA_SIZE_GET_VALIDATOR = 36 +CALLDATA_SIZE_GET_DELEGATOR = 68 +CALLDATA_SIZE_GET_WITHDRAWAL_REQUEST = 100 +CALLDATA_SIZE_GET_CONSENSUS_VALIDATOR_SET = 36 +CALLDATA_SIZE_GET_SNAPSHOT_VALIDATOR_SET = 36 +CALLDATA_SIZE_GET_EXECUTION_VALIDATOR_SET = 36 +CALLDATA_SIZE_GET_DELEGATIONS = 68 +CALLDATA_SIZE_GET_DELEGATORS = 68 +CALLDATA_SIZE_GET_EPOCH = 4 +CALLDATA_SIZE_GET_PROPOSER_VAL_ID = 4 + +# Payable functions (accept msg.value > 0) +PAYABLE_SELECTORS = frozenset( + { + SELECTOR_ADD_VALIDATOR, + SELECTOR_DELEGATE, + SELECTOR_EXTERNAL_REWARD, + } +) + + +@dataclass(frozen=True) +class FunctionInfo: + """Metadata about a staking precompile function.""" + + selector: int + gas_cost: int + calldata_size: int + is_payable: bool + name: str + return_size: int + first_return_word: int + + +# All functions with their metadata +ALL_FUNCTIONS = [ + # Setters + FunctionInfo( + SELECTOR_ADD_VALIDATOR, + GAS_ADD_VALIDATOR, + CALLDATA_SIZE_ADD_VALIDATOR, + True, + "addValidator", + 32, # returns uint64 + 1, # validator id = 1 + ), + FunctionInfo( + SELECTOR_DELEGATE, + GAS_DELEGATE, + CALLDATA_SIZE_DELEGATE, + True, + "delegate", + 0, + 0, + ), + FunctionInfo( + SELECTOR_UNDELEGATE, + GAS_UNDELEGATE, + CALLDATA_SIZE_UNDELEGATE, + False, + "undelegate", + 0, + 0, + ), + FunctionInfo( + SELECTOR_WITHDRAW, + GAS_WITHDRAW, + CALLDATA_SIZE_WITHDRAW, + False, + "withdraw", + 0, + 0, + ), + FunctionInfo( + SELECTOR_COMPOUND, + GAS_COMPOUND, + CALLDATA_SIZE_COMPOUND, + False, + "compound", + 0, + 0, + ), + FunctionInfo( + SELECTOR_CLAIM_REWARDS, + GAS_CLAIM_REWARDS, + CALLDATA_SIZE_CLAIM_REWARDS, + False, + "claimRewards", + 0, + 0, + ), + FunctionInfo( + SELECTOR_CHANGE_COMMISSION, + GAS_CHANGE_COMMISSION, + CALLDATA_SIZE_CHANGE_COMMISSION, + False, + "changeCommission", + 0, + 0, + ), + FunctionInfo( + SELECTOR_EXTERNAL_REWARD, + GAS_EXTERNAL_REWARD, + CALLDATA_SIZE_EXTERNAL_REWARD, + True, + "externalReward", + 0, + 0, + ), + # Getters + FunctionInfo( + SELECTOR_GET_VALIDATOR, + GAS_GET_VALIDATOR, + CALLDATA_SIZE_GET_VALIDATOR, + False, + "getValidator", + 32 * 20, # 20 zero words + 0, + ), + FunctionInfo( + SELECTOR_GET_DELEGATOR, + GAS_GET_DELEGATOR, + CALLDATA_SIZE_GET_DELEGATOR, + False, + "getDelegator", + 32 * 10, # 10 zero words + 0, + ), + FunctionInfo( + SELECTOR_GET_WITHDRAWAL_REQUEST, + GAS_GET_WITHDRAWAL_REQUEST, + CALLDATA_SIZE_GET_WITHDRAWAL_REQUEST, + False, + "getWithdrawalRequest", + 32 * 3, # 3 zero words + 0, + ), + FunctionInfo( + SELECTOR_GET_CONSENSUS_VALIDATOR_SET, + GAS_GET_CONSENSUS_VALIDATOR_SET, + CALLDATA_SIZE_GET_CONSENSUS_VALIDATOR_SET, + False, + "getConsensusValidatorSet", + 32 * 4, # done + cursor + offset + length + 1, # done=true + ), + FunctionInfo( + SELECTOR_GET_SNAPSHOT_VALIDATOR_SET, + GAS_GET_SNAPSHOT_VALIDATOR_SET, + CALLDATA_SIZE_GET_SNAPSHOT_VALIDATOR_SET, + False, + "getSnapshotValidatorSet", + 32 * 4, + 1, + ), + FunctionInfo( + SELECTOR_GET_EXECUTION_VALIDATOR_SET, + GAS_GET_EXECUTION_VALIDATOR_SET, + CALLDATA_SIZE_GET_EXECUTION_VALIDATOR_SET, + False, + "getExecutionValidatorSet", + 32 * 4, + 1, + ), + FunctionInfo( + SELECTOR_GET_DELEGATIONS, + GAS_GET_DELEGATIONS, + CALLDATA_SIZE_GET_DELEGATIONS, + False, + "getDelegations", + 32 * 4, + 1, # done=true + ), + FunctionInfo( + SELECTOR_GET_DELEGATORS, + GAS_GET_DELEGATORS, + CALLDATA_SIZE_GET_DELEGATORS, + False, + "getDelegators", + 32 * 4, + 1, # done=true + ), + FunctionInfo( + SELECTOR_GET_EPOCH, + GAS_GET_EPOCH, + CALLDATA_SIZE_GET_EPOCH, + False, + "getEpoch", + 32 * 2, # epoch + inBoundaryDelay + 1, # epoch=1 + ), + FunctionInfo( + SELECTOR_GET_PROPOSER_VAL_ID, + GAS_GET_PROPOSER_VAL_ID, + CALLDATA_SIZE_GET_PROPOSER_VAL_ID, + False, + "getProposerValId", + 32, # uint64 + 1, # validator_id=1 + ), +] + +GETTER_FUNCTIONS = [f for f in ALL_FUNCTIONS if f.name.startswith("get")] +SETTER_FUNCTIONS = [f for f in ALL_FUNCTIONS if not f.name.startswith("get")] + +# Lookup table: selector -> FunctionInfo +FUNC_BY_SELECTOR = {f.selector: f for f in ALL_FUNCTIONS} diff --git a/tests/monad_eight/staking_precompile/test_precompile_call.py b/tests/monad_eight/staking_precompile/test_precompile_call.py new file mode 100644 index 00000000000..1c9181333b2 --- /dev/null +++ b/tests/monad_eight/staking_precompile/test_precompile_call.py @@ -0,0 +1,580 @@ +""" +Tests for staking 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 (payable vs non-payable) +- Return data on success and revert +""" + +from enum import Enum, auto, unique +from typing import Any + +import pytest +from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + Bytecode, + Op, + StateTestFiller, + Transaction, +) +from execution_testing.forks.helpers import Fork + +from .helpers import build_calldata, generous_gas +from .spec import ( + ALL_FUNCTIONS, + FUNC_BY_SELECTOR, + GAS_UNKNOWN_SELECTOR, + STAKING_PRECOMPILE, + FunctionInfo, + ref_spec_staking, +) + +REFERENCE_SPEC_GIT_PATH = ref_spec_staking.git_path +REFERENCE_SPEC_VERSION = ref_spec_staking.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 + + +def _calldata_mem_end(calldata_size: int) -> int: + """Return the first memory offset after build_calldata's writes.""" + extra_words = max(0, (calldata_size - 4 + 31) // 32) + return 32 + extra_words * 32 + + +@unique +class CallScenario(Enum): + """Precompile call scenarios for parametrized tests.""" + + SUCCESS = auto() + TRUNCATED_SELECTOR = auto() + WRONG_SELECTOR = auto() + SHORT_CALLDATA = auto() + EXTRA_CALLDATA = auto() + STATICCALL = auto() + NONZERO_VALUE = auto() + + def should_succeed(self, func: FunctionInfo) -> bool: + """Return whether this scenario succeeds for the given function.""" + if self == CallScenario.SUCCESS: + return True + if self == CallScenario.NONZERO_VALUE: + return func.is_payable + return False + + def call_code( + self, + func: FunctionInfo, + gas: int | Bytecode, + args_offset: int = 28, + ret_offset: int = 0, + ret_size: int = 0, + ) -> Bytecode: + """Generate bytecode for this call scenario.""" + common: dict[str, Any] = dict( + gas=gas, + address=STAKING_PRECOMPILE, + args_offset=args_offset, + ret_offset=ret_offset, + ret_size=ret_size, + ) + match self: + case CallScenario.SUCCESS: + return build_calldata( + func.selector, func.calldata_size + ) + Op.CALL(args_size=func.calldata_size, **common) + case CallScenario.WRONG_SELECTOR: + return Op.MSTORE(0, 0xDEADBEEF) + Op.CALL( + args_size=4, **common + ) + case CallScenario.TRUNCATED_SELECTOR: + return Op.MSTORE(0, func.selector) + Op.CALL( + args_size=3, **common + ) + case CallScenario.SHORT_CALLDATA: + return build_calldata( + func.selector, func.calldata_size + ) + Op.CALL(args_size=func.calldata_size - 1, **common) + case CallScenario.EXTRA_CALLDATA: + return build_calldata( + func.selector, func.calldata_size + ) + Op.CALL(args_size=func.calldata_size + 1, **common) + case CallScenario.NONZERO_VALUE: + return build_calldata( + func.selector, func.calldata_size + ) + Op.CALL(args_size=func.calldata_size, value=1, **common) + case CallScenario.STATICCALL: + return build_calldata( + func.selector, func.calldata_size + ) + Op.STATICCALL(args_size=func.calldata_size, **common) + + +pytestmark = [ + pytest.mark.valid_from("MONAD_EIGHT"), + pytest.mark.pre_alloc_group( + "staking_precompile_tests", + reason="Tests staking precompile", + ), +] + + +@pytest.mark.parametrize( + "func", + [pytest.param(f, id=f.name) for f in ALL_FUNCTIONS], +) +@pytest.mark.parametrize( + "input_size", + [ + pytest.param(0, id="empty"), + pytest.param(3, id="three_bytes"), + pytest.param(4, id="selector_only"), + pytest.param(5, id="five_bytes"), + pytest.param(36, id="one_param"), + pytest.param(37, id="one_param_plus"), + pytest.param(68, id="two_params"), + pytest.param(69, id="two_params_plus"), + pytest.param(100, id="three_params"), + pytest.param(101, id="three_params_plus"), + ], +) +def test_input_size( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + func: FunctionInfo, + input_size: int, + fork: Fork, +) -> None: + """ + Test precompile behavior with various input sizes. + + Calldata must be exactly the expected size for the function. + """ + if input_size < 4: + calldata_setup = Op.PUSH32(b"\xff" * 32) + Op.PUSH1(0) + Op.MSTORE + args_offset = 32 - input_size + gas = GAS_UNKNOWN_SELECTOR + 10000 + else: + mem_size = max(input_size, func.calldata_size) + calldata_setup = build_calldata(func.selector, mem_size) + args_offset = 28 + gas = func.gas_cost + 10000 + + contract = ( + calldata_setup + + Op.SSTORE( + slot_call_success, + Op.CALL( + gas=gas, + address=STAKING_PRECOMPILE, + args_offset=args_offset, + 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 == func.calldata_size + + blockchain_test( + pre=pre, + post={ + contract_address: Account( + storage={ + slot_call_success: 1 if should_succeed else 0, + slot_return_size: ( + func.return_size if should_succeed else 0 + ), + slot_code_worked: value_code_worked, + } + ), + }, + blocks=[Block(txs=[tx])], + ) + + +@pytest.mark.parametrize( + "selector", + [pytest.param(f.selector, id=f.name) for f in ALL_FUNCTIONS] + + [ + pytest.param(0x00000000, id="zero_selector"), + pytest.param(0xFFFFFFFF, id="max_selector"), + pytest.param(0xDEADBEEF, id="deadbeef"), + ], +) +def test_selector( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + selector: int, + fork: Fork, +) -> None: + """ + Test precompile behavior with various function selectors. + + Known selectors succeed with correct calldata, unknown selectors revert. + """ + func = FUNC_BY_SELECTOR.get(selector) + + if func is not None: + calldata_setup = build_calldata(func.selector, func.calldata_size) + args_size = func.calldata_size + gas = func.gas_cost + 10000 + should_succeed = True + expected_return_size = func.return_size + else: + calldata_setup = Op.PUSH4(selector) + Op.PUSH1(0) + Op.MSTORE + args_size = 4 + gas = GAS_UNKNOWN_SELECTOR + 10000 + should_succeed = False + expected_return_size = 0 + + contract = ( + calldata_setup + + Op.SSTORE( + slot_call_success, + Op.CALL( + gas=gas, + address=STAKING_PRECOMPILE, + args_offset=28, + args_size=args_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(), + ) + + 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( + "func", + [pytest.param(f, id=f.name) for f in ALL_FUNCTIONS], +) +@pytest.mark.parametrize("enough_gas", [True, False]) +def test_gas( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + func: FunctionInfo, + enough_gas: bool, +) -> None: + """ + Test that the precompile consumes the expected gas. + """ + gas = func.gas_cost if enough_gas else func.gas_cost - 1 + + contract = ( + build_calldata(func.selector, func.calldata_size) + + Op.SSTORE( + slot_call_success, + Op.CALL( + gas=gas, + address=STAKING_PRECOMPILE, + args_offset=28, + args_size=func.calldata_size, + 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.parametrize( + "func", + [pytest.param(f, id=f.name) for f in ALL_FUNCTIONS], +) +@pytest.mark.with_all_call_opcodes() +def test_call_opcodes( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + call_opcode: Op, + func: FunctionInfo, + fork: Fork, +) -> None: + """ + Test that the precompile must be invoked via CALL only. + + STATICCALL, DELEGATECALL, and CALLCODE must revert. + """ + contract = ( + build_calldata(func.selector, func.calldata_size) + + Op.SSTORE( + slot_call_success, + call_opcode( + gas=func.gas_cost + 10000, + address=STAKING_PRECOMPILE, + args_offset=28, + args_size=func.calldata_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 = 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: ( + func.return_size if should_succeed else 0 + ), + slot_code_worked: value_code_worked, + } + ), + }, + blocks=[Block(txs=[tx])], + ) + + +@pytest.mark.parametrize( + "func", + [pytest.param(f, id=f.name) for f in ALL_FUNCTIONS], +) +@pytest.mark.parametrize("scenario", CallScenario) +def test_revert_returns( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, + func: FunctionInfo, + scenario: CallScenario, +) -> None: + """ + Test return data on success and on each revert reason. + """ + ret_offset = _calldata_mem_end(func.calldata_size) + rdc_offset = ret_offset + 32 + + contract = ( + Op.SSTORE( + slot_call_success, + scenario.call_code( + func, + gas=func.gas_cost + 10000, + ret_offset=ret_offset, + ret_size=32, + ), + ) + + Op.SSTORE(slot_return_size, Op.RETURNDATASIZE) + + Op.SSTORE(slot_ret_buffer_value, Op.MLOAD(ret_offset)) + + Op.RETURNDATACOPY(rdc_offset, 0, Op.RETURNDATASIZE) + + Op.SSTORE(slot_return_value, Op.MLOAD(rdc_offset)) + + Op.SSTORE(slot_code_worked, value_code_worked) + ) + contract_address = pre.deploy_contract(contract, balance=1) + + tx = Transaction( + gas_limit=generous_gas(fork), + to=contract_address, + sender=pre.fund_eoa(), + ) + + ok = scenario.should_succeed(func) + + blockchain_test( + pre=pre, + post={ + contract_address: Account( + storage={ + slot_call_success: 1 if ok else 0, + slot_return_size: (func.return_size if ok else 0), + slot_ret_buffer_value: ( + func.first_return_word if ok else 0 + ), + slot_return_value: (func.first_return_word if ok else 0), + slot_code_worked: value_code_worked, + } + ), + }, + blocks=[Block(txs=[tx])], + ) + + +@pytest.mark.parametrize( + "func", + [pytest.param(f, id=f.name) for f in ALL_FUNCTIONS], +) +@pytest.mark.parametrize("scenario", CallScenario) +def test_revert_consumes_all_gas( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, + func: FunctionInfo, + scenario: CallScenario, +) -> None: + """ + Test that precompile reverts consume all gas + provided to the call frame. + """ + gas_limit = generous_gas(fork) + gas_threshold = gas_limit // 64 + + contract = ( + Op.SSTORE(slot_code_worked, value_code_worked) + + Op.SSTORE(slot_call_success, 1) + + Op.SSTORE(slot_all_gas_consumed, 1) + + Op.SSTORE( + slot_call_success, + scenario.call_code(func, gas=Op.GAS), + ) + + Op.SSTORE( + slot_all_gas_consumed, + Op.LT(Op.GAS, gas_threshold), + ) + ) + contract_address = pre.deploy_contract(contract, balance=1) + + tx = Transaction( + gas_limit=gas_limit, + to=contract_address, + sender=pre.fund_eoa(), + ) + + ok = scenario.should_succeed(func) + + blockchain_test( + pre=pre, + post={ + contract_address: Account( + storage={ + slot_code_worked: value_code_worked, + slot_call_success: 1 if ok else 0, + slot_all_gas_consumed: 0 if ok else 1, + } + ), + }, + blocks=[Block(txs=[tx])], + ) + + +@pytest.mark.parametrize( + "selector", + [ + pytest.param(0x1D4E9F02, id="syscallOnEpochChange"), + pytest.param(0x791BDCF3, id="syscallReward"), + pytest.param(0x157EEB21, id="syscallSnapshot"), + ], +) +def test_syscall_rejected( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + selector: int, + fork: Fork, +) -> None: + """ + Test that syscall selectors are rejected. + + Syscalls are reserved for system transactions and must + revert when called by regular contracts. + """ + contract = ( + build_calldata(selector, 36) + + Op.SSTORE( + slot_call_success, + Op.CALL( + gas=100000, + address=STAKING_PRECOMPILE, + args_offset=28, + args_size=36, + 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(), + ) + + blockchain_test( + pre=pre, + post={ + contract_address: Account( + storage={ + slot_call_success: 0, + slot_return_size: 0, + slot_code_worked: value_code_worked, + } + ), + }, + blocks=[Block(txs=[tx])], + ) From 642271f445b0234fda96f7347dc1daa72ea5ab84 Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Wed, 11 Mar 2026 11:53:39 +0000 Subject: [PATCH 02/19] WIP: add more failing tests Co-Authored-By: Claude --- .../monad_eight/staking_precompile/helpers.py | 17 +- tests/monad_eight/staking_precompile/spec.py | 14 + .../test_fork_transition.py | 132 ++++ .../staking_precompile/test_getters.py | 205 ++++++ .../test_precompile_call.py | 629 ++++++++++++++++-- .../test_staking_lifecycle.py | 521 +++++++++++++++ 6 files changed, 1452 insertions(+), 66 deletions(-) create mode 100644 tests/monad_eight/staking_precompile/test_fork_transition.py create mode 100644 tests/monad_eight/staking_precompile/test_getters.py create mode 100644 tests/monad_eight/staking_precompile/test_staking_lifecycle.py diff --git a/tests/monad_eight/staking_precompile/helpers.py b/tests/monad_eight/staking_precompile/helpers.py index a599c30fbf0..15686441965 100644 --- a/tests/monad_eight/staking_precompile/helpers.py +++ b/tests/monad_eight/staking_precompile/helpers.py @@ -11,7 +11,20 @@ def generous_gas(fork: Fork) -> int: sstore_cost = gas_costs.G_STORAGE_SET + gas_costs.G_COLD_SLOAD deploy_cost = gas_costs.G_CODE_DEPOSIT_BYTE * len(Op.STOP) access_cost = gas_costs.G_COLD_ACCOUNT_ACCESS - return constant + 5 * sstore_cost + deploy_cost + 5 * access_cost + selfdestruct_cost = gas_costs.G_SELF_DESTRUCT + return ( + constant + + 5 * sstore_cost + + deploy_cost + + 6 * access_cost + + 3 * selfdestruct_cost + ) + + +def tx_calldata(selector: int, calldata_size: int) -> bytes: + """Build raw calldata bytes for a direct transaction.""" + sel_bytes = selector.to_bytes(4, "big") + return sel_bytes + b"\x00" * max(0, calldata_size - 4) def build_calldata(selector: int, calldata_size: int) -> Bytecode: @@ -22,7 +35,7 @@ def build_calldata(selector: int, calldata_size: int) -> Bytecode: zero-padded ABI words so that the total args region is calldata_size bytes. """ - code = Op.PUSH4(selector) + Op.PUSH1(0) + Op.MSTORE + code = Op.MSTORE(0, selector) # If calldata_size > 4, we need additional words in memory extra = calldata_size - 4 if extra > 0: diff --git a/tests/monad_eight/staking_precompile/spec.py b/tests/monad_eight/staking_precompile/spec.py index d457f8e0bc2..0e1743e49d3 100644 --- a/tests/monad_eight/staking_precompile/spec.py +++ b/tests/monad_eight/staking_precompile/spec.py @@ -15,6 +15,11 @@ class ReferenceSpec: ref_spec_staking = ReferenceSpec("staking/staking-precompile", "main") +# Error messages returned as raw ASCII revert data +ERROR_METHOD_NOT_SUPPORTED = "method not supported" +ERROR_INVALID_INPUT = "invalid input" +ERROR_VALUE_NONZERO = "value is nonzero" + # Precompile address for staking STAKING_PRECOMPILE = Address(0x1000) @@ -279,6 +284,15 @@ class FunctionInfo: GETTER_FUNCTIONS = [f for f in ALL_FUNCTIONS if f.name.startswith("get")] SETTER_FUNCTIONS = [f for f in ALL_FUNCTIONS if not f.name.startswith("get")] +PAYABLE_FUNCTIONS = [f for f in ALL_FUNCTIONS if f.is_payable] +NON_PAYABLE_FUNCTIONS = [f for f in ALL_FUNCTIONS if not f.is_payable] + +# Representative subset to limit parametrization explosion +REPRESENTATIVE_FUNCTIONS = [ + f + for f in ALL_FUNCTIONS + if f.name in ("delegate", "undelegate", "getEpoch", "getValidator") +] # Lookup table: selector -> FunctionInfo FUNC_BY_SELECTOR = {f.selector: f for f in ALL_FUNCTIONS} diff --git a/tests/monad_eight/staking_precompile/test_fork_transition.py b/tests/monad_eight/staking_precompile/test_fork_transition.py new file mode 100644 index 00000000000..3b6b54c0534 --- /dev/null +++ b/tests/monad_eight/staking_precompile/test_fork_transition.py @@ -0,0 +1,132 @@ +""" +Tests for staking precompile fork transition behavior. + +Verify that the staking precompile is not available before the +MONAD_EIGHT fork and becomes available at and after the transition. +""" + +import pytest +from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + Op, + Transaction, +) +from execution_testing.forks.helpers import Fork + +from .helpers import build_calldata, generous_gas +from .spec import ( + GAS_GET_PROPOSER_VAL_ID, + SELECTOR_GET_PROPOSER_VAL_ID, + STAKING_PRECOMPILE, + ref_spec_staking, +) + +REFERENCE_SPEC_GIT_PATH = ref_spec_staking.git_path +REFERENCE_SPEC_VERSION = ref_spec_staking.version + + +@pytest.mark.valid_at_transition_to("MONAD_EIGHT", subsequent_forks=True) +def test_fork_transition( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Test staking 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() + + # Use getProposerValId() — minimal calldata (4 bytes), low gas + callee_code = ( + build_calldata(SELECTOR_GET_PROPOSER_VAL_ID, 4) + + Op.CALL( + gas=GAS_GET_PROPOSER_VAL_ID + 10000, + address=STAKING_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={ + # Call succeeds (precompile just returns empty) + 14_999: 1, + # Call succeeds on fork transition block + 15_000: 1, + # Call continues to succeed after transition + 15_001: 1, + } + ), + callee_address: Account( + storage={ + # Precompile not available, RETURNDATASIZE==0 + 14_999: 0, + # Precompile available, RETURNDATASIZE==32 + 15_000: 1, + # Precompile continues to work + 15_001: 1, + } + ), + }, + ) diff --git a/tests/monad_eight/staking_precompile/test_getters.py b/tests/monad_eight/staking_precompile/test_getters.py new file mode 100644 index 00000000000..a92044f58a6 --- /dev/null +++ b/tests/monad_eight/staking_precompile/test_getters.py @@ -0,0 +1,205 @@ +""" +Tests for staking precompile getter return data. + +Verify that getter functions return the expected stub data structures +including correct sizes, word values, and ABI encoding. +""" + +import pytest +from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + Op, + Transaction, +) +from execution_testing.forks.helpers import Fork + +from .helpers import build_calldata, generous_gas +from .spec import ( + GETTER_FUNCTIONS, + STAKING_PRECOMPILE, + FunctionInfo, + ref_spec_staking, +) + +REFERENCE_SPEC_GIT_PATH = ref_spec_staking.git_path +REFERENCE_SPEC_VERSION = ref_spec_staking.version + +slot_code_worked = 0x1 +value_code_worked = 0x1234 +slot_call_success = 0x2 +slot_return_size = 0x3 +# Slots 0x10..0x10+N store individual return words +slot_return_word_base = 0x10 + +pytestmark = [ + pytest.mark.valid_from("MONAD_EIGHT"), + pytest.mark.pre_alloc_group( + "staking_precompile_getter_tests", + reason="Tests staking precompile getter return data", + ), +] + + +@pytest.mark.parametrize( + "func", + [pytest.param(f, id=f.name) for f in GETTER_FUNCTIONS], +) +def test_getter_return_data( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + func: FunctionInfo, + fork: Fork, +) -> None: + """ + Test that each getter returns the expected data size and content. + + Call the getter and read back RETURNDATASIZE plus each 32-byte + word of the return data. + """ + num_words = func.return_size // 32 + + # Build contract: call getter, store RETURNDATASIZE, then + # copy return data and store each word + ret_offset = 256 + contract = ( + build_calldata(func.selector, func.calldata_size) + + Op.SSTORE( + slot_call_success, + Op.CALL( + gas=func.gas_cost + 10000, + address=STAKING_PRECOMPILE, + args_offset=28, + args_size=func.calldata_size, + ret_offset=ret_offset, + ret_size=func.return_size, + ), + ) + + Op.SSTORE(slot_return_size, Op.RETURNDATASIZE) + ) + + # Copy full return data and read each word + if num_words > 0: + rdc_offset = ret_offset + func.return_size + 32 + contract += Op.RETURNDATACOPY(rdc_offset, 0, Op.RETURNDATASIZE) + for i in range(num_words): + contract += Op.SSTORE( + slot_return_word_base + i, + Op.MLOAD(rdc_offset + i * 32), + ) + + contract += Op.SSTORE(slot_code_worked, value_code_worked) + + contract_address = pre.deploy_contract(contract) + + tx = Transaction( + gas_limit=generous_gas(fork), + to=contract_address, + sender=pre.fund_eoa(), + ) + + storage: dict[int, int] = { + slot_call_success: 1, + slot_return_size: func.return_size, + slot_code_worked: value_code_worked, + } + + # Verify first return word matches expected value + if num_words > 0: + storage[slot_return_word_base] = func.first_return_word + # Remaining words should be zero for stub implementation + for i in range(1, num_words): + storage[slot_return_word_base + i] = 0 + + blockchain_test( + pre=pre, + post={contract_address: Account(storage=storage)}, + blocks=[Block(txs=[tx])], + ) + + +@pytest.mark.parametrize( + "func", + [pytest.param(f, id=f.name) for f in GETTER_FUNCTIONS], +) +def test_getter_idempotent( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + func: FunctionInfo, + fork: Fork, +) -> None: + """ + Test that calling a getter twice in the same transaction returns + identical results both times. + """ + slot_size_1 = 0x20 + slot_size_2 = 0x21 + slot_word_1 = 0x22 + slot_word_2 = 0x23 + slot_success_1 = 0x24 + slot_success_2 = 0x25 + + ret_offset_1 = 256 + ret_offset_2 = ret_offset_1 + func.return_size + 64 + + calldata_setup = build_calldata(func.selector, func.calldata_size) + + contract = ( + calldata_setup + # First call + + Op.SSTORE( + slot_success_1, + Op.CALL( + gas=func.gas_cost + 10000, + address=STAKING_PRECOMPILE, + args_offset=28, + args_size=func.calldata_size, + ret_offset=ret_offset_1, + ret_size=func.return_size, + ), + ) + + Op.SSTORE(slot_size_1, Op.RETURNDATASIZE) + + Op.SSTORE(slot_word_1, Op.MLOAD(ret_offset_1)) + # Second call (same calldata still in memory) + + Op.SSTORE( + slot_success_2, + Op.CALL( + gas=func.gas_cost + 10000, + address=STAKING_PRECOMPILE, + args_offset=28, + args_size=func.calldata_size, + ret_offset=ret_offset_2, + ret_size=func.return_size, + ), + ) + + Op.SSTORE(slot_size_2, Op.RETURNDATASIZE) + + Op.SSTORE(slot_word_2, Op.MLOAD(ret_offset_2)) + + 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(), + ) + + blockchain_test( + pre=pre, + post={ + contract_address: Account( + storage={ + slot_success_1: 1, + slot_success_2: 1, + slot_size_1: func.return_size, + slot_size_2: func.return_size, + slot_word_1: func.first_return_word, + slot_word_2: func.first_return_word, + slot_code_worked: value_code_worked, + } + ), + }, + blocks=[Block(txs=[tx])], + ) diff --git a/tests/monad_eight/staking_precompile/test_precompile_call.py b/tests/monad_eight/staking_precompile/test_precompile_call.py index 1c9181333b2..dfb5b58446b 100644 --- a/tests/monad_eight/staking_precompile/test_precompile_call.py +++ b/tests/monad_eight/staking_precompile/test_precompile_call.py @@ -10,12 +10,13 @@ """ from enum import Enum, auto, unique -from typing import Any import pytest from execution_testing import ( Account, + Address, Alloc, + AuthorizationTuple, Block, BlockchainTestFiller, Bytecode, @@ -24,12 +25,17 @@ Transaction, ) from execution_testing.forks.helpers import Fork +from execution_testing.test_types.receipt_types import TransactionReceipt -from .helpers import build_calldata, generous_gas +from .helpers import build_calldata, generous_gas, tx_calldata from .spec import ( ALL_FUNCTIONS, + ERROR_INVALID_INPUT, + ERROR_METHOD_NOT_SUPPORTED, + ERROR_VALUE_NONZERO, FUNC_BY_SELECTOR, GAS_UNKNOWN_SELECTOR, + REPRESENTATIVE_FUNCTIONS, STAKING_PRECOMPILE, FunctionInfo, ref_spec_staking, @@ -49,10 +55,22 @@ def _calldata_mem_end(calldata_size: int) -> int: """Return the first memory offset after build_calldata's writes.""" - extra_words = max(0, (calldata_size - 4 + 31) // 32) + margin = 1 + extra_words = max(0, (calldata_size - 4 + margin + 31) // 32) return 32 + extra_words * 32 +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") + + @unique class CallScenario(Enum): """Precompile call scenarios for parametrized tests.""" @@ -62,8 +80,10 @@ class CallScenario(Enum): WRONG_SELECTOR = auto() SHORT_CALLDATA = auto() EXTRA_CALLDATA = auto() - STATICCALL = auto() + NOT_CALL = auto() + DELEGATE_TO_PRECOMPILE = auto() NONZERO_VALUE = auto() + LOW_GAS = auto() def should_succeed(self, func: FunctionInfo) -> bool: """Return whether this scenario succeeds for the given function.""" @@ -73,51 +93,121 @@ def should_succeed(self, func: FunctionInfo) -> bool: return func.is_payable return False - def call_code( - self, - func: FunctionInfo, - gas: int | Bytecode, - args_offset: int = 28, - ret_offset: int = 0, - ret_size: int = 0, - ) -> Bytecode: - """Generate bytecode for this call scenario.""" - common: dict[str, Any] = dict( - gas=gas, - address=STAKING_PRECOMPILE, - args_offset=args_offset, - ret_offset=ret_offset, - ret_size=ret_size, - ) + @property + def error_message(self) -> bytes: + """Return raw ASCII error bytes for this scenario.""" match self: - case CallScenario.SUCCESS: - return build_calldata( - func.selector, func.calldata_size - ) + Op.CALL(args_size=func.calldata_size, **common) - case CallScenario.WRONG_SELECTOR: - return Op.MSTORE(0, 0xDEADBEEF) + Op.CALL( - args_size=4, **common - ) - case CallScenario.TRUNCATED_SELECTOR: - return Op.MSTORE(0, func.selector) + Op.CALL( - args_size=3, **common - ) - case CallScenario.SHORT_CALLDATA: - return build_calldata( - func.selector, func.calldata_size - ) + Op.CALL(args_size=func.calldata_size - 1, **common) - case CallScenario.EXTRA_CALLDATA: - return build_calldata( - func.selector, func.calldata_size - ) + Op.CALL(args_size=func.calldata_size + 1, **common) + case ( + CallScenario.SUCCESS + | CallScenario.NOT_CALL + | CallScenario.DELEGATE_TO_PRECOMPILE + | CallScenario.LOW_GAS + ): + return b"" + case CallScenario.WRONG_SELECTOR | CallScenario.TRUNCATED_SELECTOR: + return ERROR_METHOD_NOT_SUPPORTED.encode() + case CallScenario.SHORT_CALLDATA | CallScenario.EXTRA_CALLDATA: + return ERROR_INVALID_INPUT.encode() case CallScenario.NONZERO_VALUE: - return build_calldata( - func.selector, func.calldata_size - ) + Op.CALL(args_size=func.calldata_size, value=1, **common) - case CallScenario.STATICCALL: - return build_calldata( - func.selector, func.calldata_size - ) + Op.STATICCALL(args_size=func.calldata_size, **common) + return ERROR_VALUE_NONZERO.encode() + return b"" + + @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.TRUNCATED_SELECTOR, + CallScenario.WRONG_SELECTOR, + CallScenario.LOW_GAS, + CallScenario.NONZERO_VALUE, + CallScenario.SHORT_CALLDATA, + CallScenario.EXTRA_CALLDATA, + ] + return order.index(self) + + +def call_code( + *scenarios: CallScenario, + func: FunctionInfo, + gas: int | Bytecode = 0, + 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 + # build_calldata(selector, size) -> 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 = build_calldata( + func.selector, func.calldata_size + ) + Op.MSTORE(32, 0xDEADBEEF) + + if CallScenario.WRONG_SELECTOR in scenario_set: + args_offset = wrong_sel_args_offset + args_size = 4 + elif CallScenario.TRUNCATED_SELECTOR in scenario_set: + args_offset = correct_sel_args_offset + args_size = 3 + elif CallScenario.SHORT_CALLDATA in scenario_set: + args_offset = correct_sel_args_offset + args_size = func.calldata_size - 1 + elif CallScenario.EXTRA_CALLDATA in scenario_set: + args_offset = correct_sel_args_offset + args_size = func.calldata_size + 1 + else: + args_offset = correct_sel_args_offset + args_size = func.calldata_size + + 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 = func.gas_cost - 1 + + 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 = STAKING_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 = [ @@ -196,15 +286,20 @@ def test_input_size( should_succeed = input_size == func.calldata_size + if should_succeed: + expected_return_size = func.return_size + elif input_size < 4: + expected_return_size = len(ERROR_METHOD_NOT_SUPPORTED) + else: + expected_return_size = len(ERROR_INVALID_INPUT) + blockchain_test( pre=pre, post={ contract_address: Account( storage={ slot_call_success: 1 if should_succeed else 0, - slot_return_size: ( - func.return_size if should_succeed else 0 - ), + slot_return_size: expected_return_size, slot_code_worked: value_code_worked, } ), @@ -231,7 +326,8 @@ def test_selector( """ Test precompile behavior with various function selectors. - Known selectors succeed with correct calldata, unknown selectors revert. + Known selectors succeed with correct calldata, unknown selectors + revert. """ func = FUNC_BY_SELECTOR.get(selector) @@ -246,7 +342,7 @@ def test_selector( args_size = 4 gas = GAS_UNKNOWN_SELECTOR + 10000 should_succeed = False - expected_return_size = 0 + expected_return_size = len(ERROR_METHOD_NOT_SUPPORTED) contract = ( calldata_setup @@ -405,28 +501,58 @@ def test_call_opcodes( "func", [pytest.param(f, id=f.name) for f in ALL_FUNCTIONS], ) -@pytest.mark.parametrize("scenario", CallScenario) +@pytest.mark.parametrize( + "scenario", + [s for s in CallScenario if s != CallScenario.LOW_GAS], +) +@pytest.mark.parametrize( + "gas", + [ + pytest.param(None, id="func_gas"), + pytest.param(10000, id="10k"), + pytest.param(40000, id="40k"), + ], +) def test_revert_returns( blockchain_test: BlockchainTestFiller, pre: Alloc, fork: Fork, func: FunctionInfo, scenario: CallScenario, + gas: int | None, ) -> None: """ Test return data on success and on each revert reason. """ - ret_offset = _calldata_mem_end(func.calldata_size) + if gas is None: + gas = func.gas_cost + 10000 + + mem_end = _calldata_mem_end(func.calldata_size) + ret_offset = max(mem_end, 96) rdc_offset = ret_offset + 32 + delegating_eoa: Address | None = None + authorization_list = None + if scenario == CallScenario.DELEGATE_TO_PRECOMPILE: + delegating_eoa = pre.fund_eoa() + authorization_list = [ + AuthorizationTuple( + address=STAKING_PRECOMPILE, + nonce=0, + signer=delegating_eoa, + ) + ] + contract = ( Op.SSTORE( slot_call_success, - scenario.call_code( - func, - gas=func.gas_cost + 10000, + call_code( + scenario, + func=func, + gas=gas, ret_offset=ret_offset, ret_size=32, + delegating_eoa=delegating_eoa, ), ) + Op.SSTORE(slot_return_size, Op.RETURNDATASIZE) @@ -441,21 +567,26 @@ def test_revert_returns( gas_limit=generous_gas(fork), to=contract_address, sender=pre.fund_eoa(), + authorization_list=authorization_list, ) ok = scenario.should_succeed(func) + err = scenario.error_message + expected_return_size = func.return_size if ok else (len(err) if err else 0) + expected_mload = ( + func.first_return_word if ok else _mload_of(err) if err else 0 + ) + blockchain_test( pre=pre, post={ contract_address: Account( storage={ slot_call_success: 1 if ok else 0, - slot_return_size: (func.return_size if ok else 0), - slot_ret_buffer_value: ( - func.first_return_word if ok else 0 - ), - slot_return_value: (func.first_return_word if ok 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, } ), @@ -468,7 +599,10 @@ def test_revert_returns( "func", [pytest.param(f, id=f.name) for f in ALL_FUNCTIONS], ) -@pytest.mark.parametrize("scenario", CallScenario) +@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, @@ -483,13 +617,30 @@ def test_revert_consumes_all_gas( gas_limit = generous_gas(fork) gas_threshold = gas_limit // 64 + delegating_eoa: Address | None = None + authorization_list = None + if scenario == CallScenario.DELEGATE_TO_PRECOMPILE: + delegating_eoa = pre.fund_eoa() + authorization_list = [ + AuthorizationTuple( + address=STAKING_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, - scenario.call_code(func, gas=Op.GAS), + call_code( + scenario, + func=func, + gas=Op.GAS, + delegating_eoa=delegating_eoa, + ), ) + Op.SSTORE( slot_all_gas_consumed, @@ -502,6 +653,7 @@ def test_revert_consumes_all_gas( gas_limit=gas_limit, to=contract_address, sender=pre.fund_eoa(), + authorization_list=authorization_list, ) ok = scenario.should_succeed(func) @@ -521,6 +673,355 @@ def test_revert_consumes_all_gas( ) +@pytest.mark.parametrize( + "func", + [pytest.param(f, id=f.name) for f in ALL_FUNCTIONS], +) +@pytest.mark.parametrize("value", [0, 1]) +def test_call_with_value( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + func: FunctionInfo, + value: int, + fork: Fork, +) -> None: + """ + Test value transfer behavior for payable and non-payable functions. + + Payable functions accept value; non-payable functions revert with + "value is nonzero". + """ + mem_end = _calldata_mem_end(func.calldata_size) + ret_offset = max(mem_end, 96) + rdc_offset = ret_offset + 32 + + contract = ( + build_calldata(func.selector, func.calldata_size) + + Op.SSTORE( + slot_call_success, + Op.CALL( + gas=func.gas_cost + 10000, + address=STAKING_PRECOMPILE, + value=value, + args_offset=28, + args_size=func.calldata_size, + ret_offset=ret_offset, + ret_size=32, + ), + ) + + Op.SSTORE(slot_return_size, Op.RETURNDATASIZE) + + Op.RETURNDATACOPY(rdc_offset, 0, Op.RETURNDATASIZE) + + Op.SSTORE(slot_return_value, Op.MLOAD(rdc_offset)) + + Op.SSTORE(slot_code_worked, value_code_worked) + ) + contract_address = pre.deploy_contract(contract, balance=value) + + tx = Transaction( + gas_limit=generous_gas(fork), + to=contract_address, + sender=pre.fund_eoa(), + ) + + should_succeed = value == 0 or func.is_payable + + if should_succeed: + expected_return_size = func.return_size + expected_mload = func.first_return_word + else: + err = ERROR_VALUE_NONZERO.encode() + expected_return_size = len(err) + expected_mload = _mload_of(err) + + blockchain_test( + pre=pre, + post={ + contract_address: Account( + storage={ + slot_call_success: 1 if should_succeed else 0, + slot_return_size: expected_return_size, + slot_return_value: expected_mload, + slot_code_worked: value_code_worked, + } + ), + }, + blocks=[Block(txs=[tx])], + ) + + +# --- Check-order tests and their helpers --- + +_INCOMPATIBLE_SCENARIOS = { + frozenset({CallScenario.SHORT_CALLDATA, CallScenario.EXTRA_CALLDATA}), + frozenset({CallScenario.TRUNCATED_SELECTOR, CallScenario.SHORT_CALLDATA}), + frozenset({CallScenario.TRUNCATED_SELECTOR, CallScenario.EXTRA_CALLDATA}), + # Both produce the same error message + frozenset({CallScenario.TRUNCATED_SELECTOR, CallScenario.WRONG_SELECTOR}), +} + +_CHECK_ORDER_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 s1.check_priority < s2.check_priority + and frozenset({s1, s2}) not in _INCOMPATIBLE_SCENARIOS +] + + +@pytest.mark.parametrize( + "func", + [pytest.param(f, id=f.name) for f in REPRESENTATIVE_FUNCTIONS], +) +@pytest.mark.parametrize("scenario1,scenario2", _CHECK_ORDER_PAIRS) +def test_check_order( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, + func: FunctionInfo, + 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: 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"" + + mem_end = _calldata_mem_end(func.calldata_size) + ret_offset = max(mem_end, 96) + rdc_offset = ret_offset + 32 + + 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=STAKING_PRECOMPILE, + nonce=0, + signer=delegating_eoa, + ) + ] + + contract = ( + Op.SSTORE( + slot_call_success, + call_code( + scenario1, + scenario2, + func=func, + 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])], + ) + + +# --- Direct-transaction tests --- + + +def _tx_params( + *scenarios: CallScenario, + func: FunctionInfo, + pre: Alloc, + fork: Fork, +) -> tuple[bytes, int, Address, int]: + """ + Return (calldata, value, to, gas_limit) for tx-level scenarios. + """ + scenario_set = set(scenarios) + + if CallScenario.WRONG_SELECTOR in scenario_set: + calldata = bytes.fromhex("DEADBEEF") + elif CallScenario.TRUNCATED_SELECTOR in scenario_set: + calldata = func.selector.to_bytes(4, "big")[:3] + else: + calldata = tx_calldata(func.selector, func.calldata_size) + + if CallScenario.SHORT_CALLDATA in scenario_set: + calldata = calldata[: func.calldata_size - 1] + 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=STAKING_PRECOMPILE) + else: + to = STAKING_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 + func.gas_cost - 1 + else: + gas_limit = generous_gas(fork) + + return calldata, value, to, gas_limit + + +@pytest.mark.parametrize( + "func", + [pytest.param(f, id=f.name) for f in REPRESENTATIVE_FUNCTIONS], +) +@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, + func: FunctionInfo, + scenario: CallScenario, +) -> None: + """ + Test precompile behavior when called directly as transaction `to`. + """ + gas_price = 10 + + calldata, value, to, gas_limit = _tx_params( + scenario, func=func, 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(func) else 0, + ), + ) + + post: dict = { + sender: Account(balance=value), + } + + state_test( + pre=pre, + post=post, + tx=tx, + ) + + +_TX_INCOMPATIBLE_SCENARIOS = _INCOMPATIBLE_SCENARIOS | { + 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( + "func", + [pytest.param(f, id=f.name) for f in REPRESENTATIVE_FUNCTIONS], +) +@pytest.mark.parametrize("scenario1,scenario2", _TX_SCENARIO_PAIRS) +def test_tx_revert_scenario_pairs( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + func: FunctionInfo, + 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, func=func, 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(func) + and scenario2.should_succeed(func) + else 0x0 + ), + ) + + post: dict = { + sender: Account(balance=value), + } + + state_test( + pre=pre, + post=post, + tx=tx, + ) + + @pytest.mark.parametrize( "selector", [ diff --git a/tests/monad_eight/staking_precompile/test_staking_lifecycle.py b/tests/monad_eight/staking_precompile/test_staking_lifecycle.py new file mode 100644 index 00000000000..2994d649d88 --- /dev/null +++ b/tests/monad_eight/staking_precompile/test_staking_lifecycle.py @@ -0,0 +1,521 @@ +""" +Tests for staking precompile stateful lifecycle operations. + +These tests verify that staking operations modify state correctly: +addValidator, delegate, undelegate, compound, claimRewards, withdraw. +They are expected to FAIL against the current stub implementation, +which does not maintain any staking state. +""" + +import pytest +from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + Op, + Transaction, +) +from execution_testing.forks.helpers import Fork + +from .helpers import build_calldata, generous_gas +from .spec import ( + CALLDATA_SIZE_ADD_VALIDATOR, + CALLDATA_SIZE_CLAIM_REWARDS, + CALLDATA_SIZE_COMPOUND, + CALLDATA_SIZE_DELEGATE, + CALLDATA_SIZE_GET_DELEGATOR, + CALLDATA_SIZE_GET_VALIDATOR, + CALLDATA_SIZE_UNDELEGATE, + GAS_ADD_VALIDATOR, + GAS_CLAIM_REWARDS, + GAS_COMPOUND, + GAS_DELEGATE, + GAS_GET_DELEGATOR, + GAS_GET_VALIDATOR, + GAS_UNDELEGATE, + SELECTOR_ADD_VALIDATOR, + SELECTOR_CLAIM_REWARDS, + SELECTOR_COMPOUND, + SELECTOR_DELEGATE, + SELECTOR_GET_DELEGATOR, + SELECTOR_GET_VALIDATOR, + SELECTOR_UNDELEGATE, + STAKING_PRECOMPILE, + ref_spec_staking, +) + +REFERENCE_SPEC_GIT_PATH = ref_spec_staking.git_path +REFERENCE_SPEC_VERSION = ref_spec_staking.version + +slot_code_worked = 0x1 +value_code_worked = 0x1234 +slot_add_val_success = 0x2 +slot_add_val_return = 0x3 +slot_delegate_success = 0x4 +slot_get_val_success = 0x5 +slot_get_val_word0 = 0x6 +slot_get_delegator_success = 0x7 +slot_get_delegator_word0 = 0x8 +slot_undelegate_success = 0x9 +slot_compound_success = 0xA +slot_claim_success = 0xB + +# Stake amount: 1 MON +STAKE_AMOUNT = 10**18 + +pytestmark = [ + pytest.mark.valid_from("MONAD_EIGHT"), + pytest.mark.pre_alloc_group( + "staking_precompile_lifecycle_tests", + reason="Tests staking precompile lifecycle operations", + ), +] + + +def _call_with_value( + selector: int, + calldata_size: int, + gas_cost: int, + value: int, + ret_offset: int = 0, + ret_size: int = 0, +) -> tuple: + """ + Return (calldata_setup, call_op) for a payable precompile call. + """ + setup = build_calldata(selector, calldata_size) + call_op = Op.CALL( + gas=gas_cost + 10000, + address=STAKING_PRECOMPILE, + value=value, + args_offset=28, + args_size=calldata_size, + ret_offset=ret_offset, + ret_size=ret_size, + ) + return setup, call_op + + +def _call_no_value( + selector: int, + calldata_size: int, + gas_cost: int, + ret_offset: int = 0, + ret_size: int = 0, +) -> tuple: + """ + Return (calldata_setup, call_op) for a non-payable precompile call. + """ + setup = build_calldata(selector, calldata_size) + call_op = Op.CALL( + gas=gas_cost + 10000, + address=STAKING_PRECOMPILE, + args_offset=28, + args_size=calldata_size, + ret_offset=ret_offset, + ret_size=ret_size, + ) + return setup, call_op + + +def test_add_validator_returns_id( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Test that addValidator returns a non-zero validator ID. + + The stub returns validator ID = 1. A real implementation should + return incrementing IDs. + """ + add_setup, add_call = _call_with_value( + SELECTOR_ADD_VALIDATOR, + CALLDATA_SIZE_ADD_VALIDATOR, + GAS_ADD_VALIDATOR, + value=STAKE_AMOUNT, + ret_offset=256, + ret_size=32, + ) + + contract = ( + add_setup + + Op.SSTORE(slot_add_val_success, add_call) + + Op.SSTORE(slot_add_val_return, Op.MLOAD(256)) + + Op.SSTORE(slot_code_worked, value_code_worked) + ) + contract_address = pre.deploy_contract(contract, balance=STAKE_AMOUNT) + + tx = Transaction( + gas_limit=generous_gas(fork), + to=contract_address, + sender=pre.fund_eoa(), + ) + + blockchain_test( + pre=pre, + post={ + contract_address: Account( + storage={ + slot_add_val_success: 1, + # Expect validator ID = 1 + slot_add_val_return: 1, + slot_code_worked: value_code_worked, + } + ), + }, + blocks=[Block(txs=[tx])], + ) + + +def test_add_validator_then_get( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Test that getValidator reflects state set by addValidator. + + After addValidator, getValidator(id) should return non-zero + fields. The stub returns all zeros, so this test is expected + to FAIL. + """ + # addValidator returns validator ID at ret_offset=256 + add_setup, add_call = _call_with_value( + SELECTOR_ADD_VALIDATOR, + CALLDATA_SIZE_ADD_VALIDATOR, + GAS_ADD_VALIDATOR, + value=STAKE_AMOUNT, + ret_offset=256, + ret_size=32, + ) + + # getValidator(uint64 validatorId) - ID=1 at mem[32] + get_setup, get_call = _call_no_value( + SELECTOR_GET_VALIDATOR, + CALLDATA_SIZE_GET_VALIDATOR, + GAS_GET_VALIDATOR, + ret_offset=512, + ret_size=32 * 20, + ) + + contract = ( + add_setup + + Op.SSTORE(slot_add_val_success, add_call) + + Op.SSTORE(slot_add_val_return, Op.MLOAD(256)) + # Now call getValidator with ID=1 (already stored at mem[32]) + + get_setup + + Op.SSTORE(slot_get_val_success, get_call) + # First word of validator struct should be non-zero + # (e.g., stake amount or validator pubkey hash) + + Op.SSTORE(slot_get_val_word0, Op.MLOAD(512)) + + Op.SSTORE(slot_code_worked, value_code_worked) + ) + contract_address = pre.deploy_contract(contract, balance=STAKE_AMOUNT) + + tx = Transaction( + gas_limit=generous_gas(fork), + to=contract_address, + sender=pre.fund_eoa(), + ) + + blockchain_test( + pre=pre, + post={ + contract_address: Account( + storage={ + slot_add_val_success: 1, + slot_add_val_return: 1, + slot_get_val_success: 1, + # FAIL: stub returns 0; real impl returns + # non-zero validator data + slot_get_val_word0: 1, + slot_code_worked: value_code_worked, + } + ), + }, + blocks=[Block(txs=[tx])], + ) + + +def test_delegate_then_get_delegator( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Test that getDelegator reflects state set by delegate. + + After addValidator + delegate, getDelegator should return + non-zero delegation info. Expected to FAIL against stub. + """ + # addValidator + add_setup, add_call = _call_with_value( + SELECTOR_ADD_VALIDATOR, + CALLDATA_SIZE_ADD_VALIDATOR, + GAS_ADD_VALIDATOR, + value=STAKE_AMOUNT, + ret_offset=256, + ret_size=32, + ) + + # delegate(uint64 validatorId) with value + del_setup, del_call = _call_with_value( + SELECTOR_DELEGATE, + CALLDATA_SIZE_DELEGATE, + GAS_DELEGATE, + value=STAKE_AMOUNT, + ) + + # getDelegator(uint64 validatorId, address delegator) + get_setup, get_call = _call_no_value( + SELECTOR_GET_DELEGATOR, + CALLDATA_SIZE_GET_DELEGATOR, + GAS_GET_DELEGATOR, + ret_offset=512, + ret_size=32 * 10, + ) + + contract = ( + add_setup + + Op.SSTORE(slot_add_val_success, add_call) + + del_setup + + Op.SSTORE(slot_delegate_success, del_call) + + get_setup + + Op.SSTORE(slot_get_delegator_success, get_call) + # First word should contain delegation amount + + Op.SSTORE(slot_get_delegator_word0, Op.MLOAD(512)) + + Op.SSTORE(slot_code_worked, value_code_worked) + ) + contract_address = pre.deploy_contract(contract, balance=STAKE_AMOUNT * 2) + + tx = Transaction( + gas_limit=generous_gas(fork), + to=contract_address, + sender=pre.fund_eoa(), + ) + + blockchain_test( + pre=pre, + post={ + contract_address: Account( + storage={ + slot_add_val_success: 1, + slot_delegate_success: 1, + slot_get_delegator_success: 1, + # FAIL: stub returns 0; real impl returns + # non-zero delegation data + slot_get_delegator_word0: 1, + slot_code_worked: value_code_worked, + } + ), + }, + blocks=[Block(txs=[tx])], + ) + + +def test_undelegate_after_delegate( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Test that undelegate succeeds after a prior delegation. + + Expected to FAIL against stub (undelegate stub doesn't verify + prior delegation exists). + """ + # addValidator + add_setup, add_call = _call_with_value( + SELECTOR_ADD_VALIDATOR, + CALLDATA_SIZE_ADD_VALIDATOR, + GAS_ADD_VALIDATOR, + value=STAKE_AMOUNT, + ret_offset=256, + ret_size=32, + ) + + # delegate + del_setup, del_call = _call_with_value( + SELECTOR_DELEGATE, + CALLDATA_SIZE_DELEGATE, + GAS_DELEGATE, + value=STAKE_AMOUNT, + ) + + # undelegate(uint64 validatorId, uint256 amount, uint8 type) + undel_setup, undel_call = _call_no_value( + SELECTOR_UNDELEGATE, + CALLDATA_SIZE_UNDELEGATE, + GAS_UNDELEGATE, + ) + + contract = ( + add_setup + + Op.SSTORE(slot_add_val_success, add_call) + + del_setup + + Op.SSTORE(slot_delegate_success, del_call) + + undel_setup + + Op.SSTORE(slot_undelegate_success, undel_call) + + Op.SSTORE(slot_code_worked, value_code_worked) + ) + contract_address = pre.deploy_contract(contract, balance=STAKE_AMOUNT * 2) + + tx = Transaction( + gas_limit=generous_gas(fork), + to=contract_address, + sender=pre.fund_eoa(), + ) + + blockchain_test( + pre=pre, + post={ + contract_address: Account( + storage={ + slot_add_val_success: 1, + slot_delegate_success: 1, + slot_undelegate_success: 1, + slot_code_worked: value_code_worked, + } + ), + }, + blocks=[Block(txs=[tx])], + ) + + +def test_compound_rewards( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Test that compound succeeds after delegation. + + Expected to FAIL against stub (compound doesn't verify state). + """ + # addValidator + add_setup, add_call = _call_with_value( + SELECTOR_ADD_VALIDATOR, + CALLDATA_SIZE_ADD_VALIDATOR, + GAS_ADD_VALIDATOR, + value=STAKE_AMOUNT, + ret_offset=256, + ret_size=32, + ) + + # delegate + del_setup, del_call = _call_with_value( + SELECTOR_DELEGATE, + CALLDATA_SIZE_DELEGATE, + GAS_DELEGATE, + value=STAKE_AMOUNT, + ) + + # compound(uint64 validatorId) + comp_setup, comp_call = _call_no_value( + SELECTOR_COMPOUND, + CALLDATA_SIZE_COMPOUND, + GAS_COMPOUND, + ) + + contract = ( + add_setup + + Op.SSTORE(slot_add_val_success, add_call) + + del_setup + + Op.SSTORE(slot_delegate_success, del_call) + + comp_setup + + Op.SSTORE(slot_compound_success, comp_call) + + Op.SSTORE(slot_code_worked, value_code_worked) + ) + contract_address = pre.deploy_contract(contract, balance=STAKE_AMOUNT * 2) + + tx = Transaction( + gas_limit=generous_gas(fork), + to=contract_address, + sender=pre.fund_eoa(), + ) + + blockchain_test( + pre=pre, + post={ + contract_address: Account( + storage={ + slot_add_val_success: 1, + slot_delegate_success: 1, + slot_compound_success: 1, + slot_code_worked: value_code_worked, + } + ), + }, + blocks=[Block(txs=[tx])], + ) + + +def test_claim_rewards( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Test that claimRewards succeeds after delegation. + + Expected to FAIL against stub (claimRewards doesn't verify + state). + """ + # addValidator + add_setup, add_call = _call_with_value( + SELECTOR_ADD_VALIDATOR, + CALLDATA_SIZE_ADD_VALIDATOR, + GAS_ADD_VALIDATOR, + value=STAKE_AMOUNT, + ret_offset=256, + ret_size=32, + ) + + # delegate + del_setup, del_call = _call_with_value( + SELECTOR_DELEGATE, + CALLDATA_SIZE_DELEGATE, + GAS_DELEGATE, + value=STAKE_AMOUNT, + ) + + # claimRewards(uint64 validatorId) + claim_setup, claim_call = _call_no_value( + SELECTOR_CLAIM_REWARDS, + CALLDATA_SIZE_CLAIM_REWARDS, + GAS_CLAIM_REWARDS, + ) + + contract = ( + add_setup + + Op.SSTORE(slot_add_val_success, add_call) + + del_setup + + Op.SSTORE(slot_delegate_success, del_call) + + claim_setup + + Op.SSTORE(slot_claim_success, claim_call) + + Op.SSTORE(slot_code_worked, value_code_worked) + ) + contract_address = pre.deploy_contract(contract, balance=STAKE_AMOUNT * 2) + + tx = Transaction( + gas_limit=generous_gas(fork), + to=contract_address, + sender=pre.fund_eoa(), + ) + + blockchain_test( + pre=pre, + post={ + contract_address: Account( + storage={ + slot_add_val_success: 1, + slot_delegate_success: 1, + slot_claim_success: 1, + slot_code_worked: value_code_worked, + } + ), + }, + blocks=[Block(txs=[tx])], + ) From 67b356363d4a88e7451cbba24f5d349f15c8db23 Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Wed, 11 Mar 2026 17:43:50 +0000 Subject: [PATCH 03/19] Staking precompile stubs return empty-state values Co-Authored-By: Claude claude-opus-4-6 --- .../vm/precompiled_contracts/staking.py | 34 ++-- .../vm/precompiled_contracts/staking.py | 34 ++-- .../monad_eight/staking_precompile/helpers.py | 15 +- tests/monad_eight/staking_precompile/spec.py | 6 +- .../test_fork_transition.py | 12 +- .../staking_precompile/test_getters.py | 19 +-- .../test_precompile_call.py | 149 +++++++++++------- .../test_staking_lifecycle.py | 24 +-- 8 files changed, 168 insertions(+), 125 deletions(-) diff --git a/src/ethereum/forks/monad_eight/vm/precompiled_contracts/staking.py b/src/ethereum/forks/monad_eight/vm/precompiled_contracts/staking.py index b4ca046e696..0d6af937940 100644 --- a/src/ethereum/forks/monad_eight/vm/precompiled_contracts/staking.py +++ b/src/ethereum/forks/monad_eight/vm/precompiled_contracts/staking.py @@ -17,7 +17,7 @@ from ethereum_types.numeric import U256, Uint from ...vm import Evm -from ...vm.exceptions import InvalidParameter +from ...vm.exceptions import InvalidParameter, RevertInMonadPrecompile from ...vm.gas import charge_gas # Gas costs per function (from staking spec) @@ -196,20 +196,20 @@ def _handle_get_epoch(evm: Evm) -> None: """ Handle getEpoch() call. - Return stub: epoch=1, in_boundary_delay=false. + Return stub: epoch=0, in_boundary_delay=false. """ # Returns (uint64 epoch, bool inBoundaryDelay) - evm.output = _abi_encode_uint256(1) + _abi_encode_bool(False) + evm.output = _abi_encode_uint256(0) + _abi_encode_bool(False) def _handle_get_proposer_val_id(evm: Evm) -> None: """ Handle getProposerValId() call. - Return stub: validator_id=1. + Return stub: validator_id=0 (no validators registered). """ # Returns uint64 - evm.output = _abi_encode_uint256(1) + evm.output = _abi_encode_uint256(0) def _handle_get_validator(evm: Evm) -> None: @@ -325,10 +325,14 @@ def staking(evm: Evm) -> None: """ data = evm.message.data + # Must be invoked via CALL only + _validate_call_type(evm) + # Must have at least 4 bytes for the selector if len(data) < 4: charge_gas(evm, GAS_UNKNOWN_SELECTOR) - raise InvalidParameter + evm.output = b"method not supported" + raise RevertInMonadPrecompile selector = bytes(data[:4]) @@ -336,23 +340,27 @@ def staking(evm: Evm) -> None: info = _SELECTOR_INFO.get(selector) if info is None: charge_gas(evm, GAS_UNKNOWN_SELECTOR) - raise InvalidParameter + evm.output = b"method not supported" + raise RevertInMonadPrecompile gas_cost, is_payable, expected_size = info # GAS charge_gas(evm, gas_cost) - # Must be invoked via CALL only - _validate_call_type(evm) + # Syscall selectors are always rejected from regular user calls + if selector in _SYSCALL_SELECTORS: + raise InvalidParameter # Non-payable functions reject nonzero value if not is_payable and evm.message.value != 0: - raise InvalidParameter + evm.output = b"value is nonzero" + raise RevertInMonadPrecompile # Validate calldata size if len(data) != expected_size: - raise InvalidParameter + evm.output = b"invalid input" + raise RevertInMonadPrecompile # Dispatch if selector in _GETTER_SELECTORS: @@ -363,8 +371,8 @@ def staking(evm: Evm) -> None: # Return empty output (setters don't return data except # addValidator which returns a uint64) if selector == SELECTOR_ADD_VALIDATOR: - # addValidator returns the new validator ID - evm.output = _abi_encode_uint256(1) + # addValidator returns validator ID; 0 = no validator created + evm.output = _abi_encode_uint256(0) else: evm.output = Bytes(b"") elif selector in _SYSCALL_SELECTORS: diff --git a/src/ethereum/forks/monad_nine/vm/precompiled_contracts/staking.py b/src/ethereum/forks/monad_nine/vm/precompiled_contracts/staking.py index b4ca046e696..0d6af937940 100644 --- a/src/ethereum/forks/monad_nine/vm/precompiled_contracts/staking.py +++ b/src/ethereum/forks/monad_nine/vm/precompiled_contracts/staking.py @@ -17,7 +17,7 @@ from ethereum_types.numeric import U256, Uint from ...vm import Evm -from ...vm.exceptions import InvalidParameter +from ...vm.exceptions import InvalidParameter, RevertInMonadPrecompile from ...vm.gas import charge_gas # Gas costs per function (from staking spec) @@ -196,20 +196,20 @@ def _handle_get_epoch(evm: Evm) -> None: """ Handle getEpoch() call. - Return stub: epoch=1, in_boundary_delay=false. + Return stub: epoch=0, in_boundary_delay=false. """ # Returns (uint64 epoch, bool inBoundaryDelay) - evm.output = _abi_encode_uint256(1) + _abi_encode_bool(False) + evm.output = _abi_encode_uint256(0) + _abi_encode_bool(False) def _handle_get_proposer_val_id(evm: Evm) -> None: """ Handle getProposerValId() call. - Return stub: validator_id=1. + Return stub: validator_id=0 (no validators registered). """ # Returns uint64 - evm.output = _abi_encode_uint256(1) + evm.output = _abi_encode_uint256(0) def _handle_get_validator(evm: Evm) -> None: @@ -325,10 +325,14 @@ def staking(evm: Evm) -> None: """ data = evm.message.data + # Must be invoked via CALL only + _validate_call_type(evm) + # Must have at least 4 bytes for the selector if len(data) < 4: charge_gas(evm, GAS_UNKNOWN_SELECTOR) - raise InvalidParameter + evm.output = b"method not supported" + raise RevertInMonadPrecompile selector = bytes(data[:4]) @@ -336,23 +340,27 @@ def staking(evm: Evm) -> None: info = _SELECTOR_INFO.get(selector) if info is None: charge_gas(evm, GAS_UNKNOWN_SELECTOR) - raise InvalidParameter + evm.output = b"method not supported" + raise RevertInMonadPrecompile gas_cost, is_payable, expected_size = info # GAS charge_gas(evm, gas_cost) - # Must be invoked via CALL only - _validate_call_type(evm) + # Syscall selectors are always rejected from regular user calls + if selector in _SYSCALL_SELECTORS: + raise InvalidParameter # Non-payable functions reject nonzero value if not is_payable and evm.message.value != 0: - raise InvalidParameter + evm.output = b"value is nonzero" + raise RevertInMonadPrecompile # Validate calldata size if len(data) != expected_size: - raise InvalidParameter + evm.output = b"invalid input" + raise RevertInMonadPrecompile # Dispatch if selector in _GETTER_SELECTORS: @@ -363,8 +371,8 @@ def staking(evm: Evm) -> None: # Return empty output (setters don't return data except # addValidator which returns a uint64) if selector == SELECTOR_ADD_VALIDATOR: - # addValidator returns the new validator ID - evm.output = _abi_encode_uint256(1) + # addValidator returns validator ID; 0 = no validator created + evm.output = _abi_encode_uint256(0) else: evm.output = Bytes(b"") elif selector in _SYSCALL_SELECTORS: diff --git a/tests/monad_eight/staking_precompile/helpers.py b/tests/monad_eight/staking_precompile/helpers.py index 15686441965..2f16f68c5fb 100644 --- a/tests/monad_eight/staking_precompile/helpers.py +++ b/tests/monad_eight/staking_precompile/helpers.py @@ -31,16 +31,9 @@ def build_calldata(selector: int, calldata_size: int) -> Bytecode: """ Build bytecode that stores a selector and padding in memory. - Place the 4-byte selector at mem[28:32] and fill mem[32:..] with - zero-padded ABI words so that the total args region is calldata_size - bytes. + Place the 4-byte selector at mem[60:64] and assume mem[64:..] has + rest of calldata """ - code = Op.MSTORE(0, selector) - # If calldata_size > 4, we need additional words in memory - extra = calldata_size - 4 - if extra > 0: - words = (extra + 31) // 32 - for i in range(words): - # Store a dummy uint256 value (1) for each ABI param word - code += Op.MSTORE(32 + i * 32, 1) + selector_calldata_offset = 32 + code = Op.MSTORE(selector_calldata_offset, selector) return code diff --git a/tests/monad_eight/staking_precompile/spec.py b/tests/monad_eight/staking_precompile/spec.py index 0e1743e49d3..77461d10fd4 100644 --- a/tests/monad_eight/staking_precompile/spec.py +++ b/tests/monad_eight/staking_precompile/spec.py @@ -124,7 +124,7 @@ class FunctionInfo: True, "addValidator", 32, # returns uint64 - 1, # validator id = 1 + 0, # validator id = 0 (no validator created) ), FunctionInfo( SELECTOR_DELEGATE, @@ -269,7 +269,7 @@ class FunctionInfo: False, "getEpoch", 32 * 2, # epoch + inBoundaryDelay - 1, # epoch=1 + 0, # epoch=0 (no epochs occurred) ), FunctionInfo( SELECTOR_GET_PROPOSER_VAL_ID, @@ -278,7 +278,7 @@ class FunctionInfo: False, "getProposerValId", 32, # uint64 - 1, # validator_id=1 + 0, # validator_id=0 (no validators registered) ), ] diff --git a/tests/monad_eight/staking_precompile/test_fork_transition.py b/tests/monad_eight/staking_precompile/test_fork_transition.py index 3b6b54c0534..ee546adab6d 100644 --- a/tests/monad_eight/staking_precompile/test_fork_transition.py +++ b/tests/monad_eight/staking_precompile/test_fork_transition.py @@ -14,6 +14,8 @@ Op, Transaction, ) +from execution_testing.forks import get_transition_fork_predecessor +from execution_testing.forks.forks.forks import MONAD_EIGHT from execution_testing.forks.helpers import Fork from .helpers import build_calldata, generous_gas @@ -43,13 +45,17 @@ def test_fork_transition( """ sender = pre.fund_eoa() + # True when the fork before the transition already has the staking + # precompile (e.g. MONAD_EIGHT → MONAD_NINE transition). + staking_pre = get_transition_fork_predecessor(fork) >= MONAD_EIGHT + # Use getProposerValId() — minimal calldata (4 bytes), low gas callee_code = ( build_calldata(SELECTOR_GET_PROPOSER_VAL_ID, 4) + Op.CALL( gas=GAS_GET_PROPOSER_VAL_ID + 10000, address=STAKING_PRECOMPILE, - args_offset=28, + args_offset=60, args_size=4, ret_offset=0, ret_size=32, @@ -120,8 +126,8 @@ def test_fork_transition( ), callee_address: Account( storage={ - # Precompile not available, RETURNDATASIZE==0 - 14_999: 0, + # Pre-transition: available iff predecessor >= MONAD_EIGHT + 14_999: 1 if staking_pre else 0, # Precompile available, RETURNDATASIZE==32 15_000: 1, # Precompile continues to work diff --git a/tests/monad_eight/staking_precompile/test_getters.py b/tests/monad_eight/staking_precompile/test_getters.py index a92044f58a6..7ed8244b02c 100644 --- a/tests/monad_eight/staking_precompile/test_getters.py +++ b/tests/monad_eight/staking_precompile/test_getters.py @@ -71,7 +71,7 @@ def test_getter_return_data( Op.CALL( gas=func.gas_cost + 10000, address=STAKING_PRECOMPILE, - args_offset=28, + args_offset=60, args_size=func.calldata_size, ret_offset=ret_offset, ret_size=func.return_size, @@ -80,15 +80,10 @@ def test_getter_return_data( + Op.SSTORE(slot_return_size, Op.RETURNDATASIZE) ) - # Copy full return data and read each word if num_words > 0: rdc_offset = ret_offset + func.return_size + 32 contract += Op.RETURNDATACOPY(rdc_offset, 0, Op.RETURNDATASIZE) - for i in range(num_words): - contract += Op.SSTORE( - slot_return_word_base + i, - Op.MLOAD(rdc_offset + i * 32), - ) + contract += Op.SSTORE(slot_return_word_base, Op.MLOAD(rdc_offset)) contract += Op.SSTORE(slot_code_worked, value_code_worked) @@ -106,12 +101,8 @@ def test_getter_return_data( slot_code_worked: value_code_worked, } - # Verify first return word matches expected value if num_words > 0: storage[slot_return_word_base] = func.first_return_word - # Remaining words should be zero for stub implementation - for i in range(1, num_words): - storage[slot_return_word_base + i] = 0 blockchain_test( pre=pre, @@ -154,7 +145,7 @@ def test_getter_idempotent( Op.CALL( gas=func.gas_cost + 10000, address=STAKING_PRECOMPILE, - args_offset=28, + args_offset=60, args_size=func.calldata_size, ret_offset=ret_offset_1, ret_size=func.return_size, @@ -168,7 +159,7 @@ def test_getter_idempotent( Op.CALL( gas=func.gas_cost + 10000, address=STAKING_PRECOMPILE, - args_offset=28, + args_offset=60, args_size=func.calldata_size, ret_offset=ret_offset_2, ret_size=func.return_size, @@ -181,7 +172,7 @@ def test_getter_idempotent( contract_address = pre.deploy_contract(contract) tx = Transaction( - gas_limit=generous_gas(fork), + gas_limit=generous_gas(fork) + 2 * func.gas_cost, to=contract_address, sender=pre.fund_eoa(), ) diff --git a/tests/monad_eight/staking_precompile/test_precompile_call.py b/tests/monad_eight/staking_precompile/test_precompile_call.py index dfb5b58446b..9567736f161 100644 --- a/tests/monad_eight/staking_precompile/test_precompile_call.py +++ b/tests/monad_eight/staking_precompile/test_precompile_call.py @@ -10,6 +10,7 @@ """ from enum import Enum, auto, unique +from typing import Any import pytest from execution_testing import ( @@ -24,6 +25,7 @@ StateTestFiller, Transaction, ) +from execution_testing.forks.forks.forks import MONAD_NINE from execution_testing.forks.helpers import Fork from execution_testing.test_types.receipt_types import TransactionReceipt @@ -55,9 +57,7 @@ def _calldata_mem_end(calldata_size: int) -> int: """Return the first memory offset after build_calldata's writes.""" - margin = 1 - extra_words = max(0, (calldata_size - 4 + margin + 31) // 32) - return 32 + extra_words * 32 + return 60 + calldata_size + 1 def _mload_of(msg: bytes) -> int: @@ -148,14 +148,16 @@ def call_code( scenario_set = set(scenarios) # Memory layout: non-overlapping buffers - # build_calldata(selector, size) -> selector at mem[28:32] - # MSTORE(32, 0xDEADBEEF) -> wrong selector at mem[60:64] - correct_sel_args_offset = 28 - wrong_sel_args_offset = 60 + # MSTORE(0, 0xDEADBEEF) -> wrong selector at mem[28:32] + # build_calldata(selector, size) -> selector at mem[60:64] + wrong_sel_args_offset = 28 + correct_sel_args_offset = 60 + # NOTE: in case of wrong selector scenario, we do not include any + # extra calldata for (non-existent) function arguments setup: Bytecode = build_calldata( func.selector, func.calldata_size - ) + Op.MSTORE(32, 0xDEADBEEF) + ) + Op.MSTORE(0, 0xDEADBEEF) if CallScenario.WRONG_SELECTOR in scenario_set: args_offset = wrong_sel_args_offset @@ -179,7 +181,13 @@ def call_code( ) if CallScenario.LOW_GAS in scenario_set: - gas = func.gas_cost - 1 + if ( + CallScenario.WRONG_SELECTOR in scenario_set + or CallScenario.TRUNCATED_SELECTOR in scenario_set + ): + gas = GAS_UNKNOWN_SELECTOR - 1 + else: + gas = func.gas_cost - 1 if CallScenario.NONZERO_VALUE in scenario_set: value = 1 @@ -219,30 +227,26 @@ def call_code( ] +def input_size_delta(fork: Fork) -> Any: + """Input size delta, large input allowed for linear memory forks.""" + yield pytest.param(-1, id="short") + yield pytest.param(0, id="correct") + yield pytest.param(1, id="byte_extra") + yield pytest.param(32, id="word_extra") + if fork >= MONAD_NINE: + yield pytest.param(7 * 1024 * 1024, id="7MB_extra") + + @pytest.mark.parametrize( "func", [pytest.param(f, id=f.name) for f in ALL_FUNCTIONS], ) -@pytest.mark.parametrize( - "input_size", - [ - pytest.param(0, id="empty"), - pytest.param(3, id="three_bytes"), - pytest.param(4, id="selector_only"), - pytest.param(5, id="five_bytes"), - pytest.param(36, id="one_param"), - pytest.param(37, id="one_param_plus"), - pytest.param(68, id="two_params"), - pytest.param(69, id="two_params_plus"), - pytest.param(100, id="three_params"), - pytest.param(101, id="three_params_plus"), - ], -) +@pytest.mark.parametrize_by_fork("input_size_delta", input_size_delta) def test_input_size( blockchain_test: BlockchainTestFiller, pre: Alloc, func: FunctionInfo, - input_size: int, + input_size_delta: int, fork: Fork, ) -> None: """ @@ -250,15 +254,14 @@ def test_input_size( Calldata must be exactly the expected size for the function. """ + input_size = func.calldata_size + input_size_delta if input_size < 4: - calldata_setup = Op.PUSH32(b"\xff" * 32) + Op.PUSH1(0) + Op.MSTORE - args_offset = 32 - input_size gas = GAS_UNKNOWN_SELECTOR + 10000 else: - mem_size = max(input_size, func.calldata_size) - calldata_setup = build_calldata(func.selector, mem_size) - args_offset = 28 gas = func.gas_cost + 10000 + calldata_setup = build_calldata( + func.selector, func.calldata_size + input_size_delta + ) contract = ( calldata_setup @@ -267,7 +270,7 @@ def test_input_size( Op.CALL( gas=gas, address=STAKING_PRECOMPILE, - args_offset=args_offset, + args_offset=60, args_size=input_size, ret_offset=0, ret_size=32, @@ -338,7 +341,7 @@ def test_selector( should_succeed = True expected_return_size = func.return_size else: - calldata_setup = Op.PUSH4(selector) + Op.PUSH1(0) + Op.MSTORE + calldata_setup = Op.MSTORE(32, selector) args_size = 4 gas = GAS_UNKNOWN_SELECTOR + 10000 should_succeed = False @@ -351,7 +354,7 @@ def test_selector( Op.CALL( gas=gas, address=STAKING_PRECOMPILE, - args_offset=28, + args_offset=60, args_size=args_size, ret_offset=0, ret_size=32, @@ -407,7 +410,7 @@ def test_gas( Op.CALL( gas=gas, address=STAKING_PRECOMPILE, - args_offset=28, + args_offset=60, args_size=func.calldata_size, ret_offset=0, ret_size=32, @@ -461,7 +464,7 @@ def test_call_opcodes( call_opcode( gas=func.gas_cost + 10000, address=STAKING_PRECOMPILE, - args_offset=28, + args_offset=60, args_size=func.calldata_size, ret_offset=0, ret_size=32, @@ -505,27 +508,20 @@ def test_call_opcodes( "scenario", [s for s in CallScenario if s != CallScenario.LOW_GAS], ) -@pytest.mark.parametrize( - "gas", - [ - pytest.param(None, id="func_gas"), - pytest.param(10000, id="10k"), - pytest.param(40000, id="40k"), - ], -) def test_revert_returns( blockchain_test: BlockchainTestFiller, pre: Alloc, fork: Fork, func: FunctionInfo, scenario: CallScenario, - gas: int | None, ) -> None: """ Test return data on success and on each revert reason. """ - if gas is None: - gas = func.gas_cost + 10000 + # Always provide enough gas regardless of scenario: + # TRUNCATED_SELECTOR/WRONG_SELECTOR charge GAS_UNKNOWN_SELECTOR + # before the function's own gas cost is known. + gas = max(func.gas_cost, GAS_UNKNOWN_SELECTOR) + 10000 mem_end = _calldata_mem_end(func.calldata_size) ret_offset = max(mem_end, 96) @@ -572,7 +568,12 @@ def test_revert_returns( ok = scenario.should_succeed(func) - err = scenario.error_message + if scenario == CallScenario.SHORT_CALLDATA and func.calldata_size == 4: + # SHORT_CALLDATA on a 4-byte function sends 3 bytes, hitting the + # truncated-selector path rather than the size-mismatch path. + err = ERROR_METHOD_NOT_SUPPORTED.encode() + else: + err = scenario.error_message expected_return_size = func.return_size if ok else (len(err) if err else 0) expected_mload = ( func.first_return_word if ok else _mload_of(err) if err else 0 @@ -703,7 +704,7 @@ def test_call_with_value( gas=func.gas_cost + 10000, address=STAKING_PRECOMPILE, value=value, - args_offset=28, + args_offset=60, args_size=func.calldata_size, ret_offset=ret_offset, ret_size=32, @@ -787,14 +788,44 @@ def test_check_order( Each combination triggers exactly two failure causes. The test derives the expected outcome from the higher-priority failure. """ - if frozenset({scenario1, scenario2}) == frozenset( + scenarios_set = frozenset({scenario1, scenario2}) + call_succeeds = False + + if scenarios_set == frozenset( {CallScenario.LOW_GAS, CallScenario.NONZERO_VALUE} ): - # Edge case: call gas stipend overrides low gas - expected_msg = CallScenario.NONZERO_VALUE.error_message + if func.is_payable: + # EVM adds 2300 stipend for value>0, overcoming LOW_GAS; + # payable func accepts value -> call succeeds + expected_msg = b"" + call_succeeds = True + else: + expected_msg = CallScenario.NONZERO_VALUE.error_message + elif CallScenario.LOW_GAS in scenarios_set and ( + CallScenario.TRUNCATED_SELECTOR in scenarios_set + or CallScenario.WRONG_SELECTOR in scenarios_set + ): + # gas = GAS_UNKNOWN_SELECTOR-1 < GAS_UNKNOWN_SELECTOR -> OOG + expected_msg = b"" else: prevailing = min(scenario1, scenario2, key=lambda s: s.check_priority) expected_msg = prevailing.error_message or b"" + if prevailing == CallScenario.NONZERO_VALUE: + if ( + CallScenario.SHORT_CALLDATA in scenarios_set + and func.calldata_size == 4 + ): + # SHORT on a 4-byte func sends 3 bytes -> truncated + # selector path -> "method not supported" + expected_msg = ERROR_METHOD_NOT_SUPPORTED.encode() + elif func.is_payable: + # Payable accepts value; the other scenario fires + other = ( + scenario1 + if scenario1 != CallScenario.NONZERO_VALUE + else scenario2 + ) + expected_msg = other.error_message or b"" mem_end = _calldata_mem_end(func.calldata_size) ret_offset = max(mem_end, 96) @@ -819,6 +850,7 @@ def test_check_order( scenario1, scenario2, func=func, + gas=max(func.gas_cost, GAS_UNKNOWN_SELECTOR) + 10000, ret_offset=ret_offset, ret_size=32, delegating_eoa=delegating_eoa, @@ -846,7 +878,7 @@ def test_check_order( post={ contract_address: Account( storage={ - slot_call_success: 0, + slot_call_success: 1 if call_succeeds else 0, slot_return_size: expected_return_size, slot_return_value: expected_mload, slot_code_worked: value_code_worked, @@ -944,9 +976,14 @@ def test_tx_revert_scenarios( ), ) - post: dict = { - sender: Account(balance=value), - } + if scenario.should_succeed(func): + # Value was transferred to precompile + post = { + sender: Account(balance=0), + STAKING_PRECOMPILE: Account(balance=value) if value > 0 else None, + } + else: + post = {sender: Account(balance=value)} state_test( pre=pre, @@ -1049,7 +1086,7 @@ def test_syscall_rejected( Op.CALL( gas=100000, address=STAKING_PRECOMPILE, - args_offset=28, + args_offset=60, args_size=36, ret_offset=0, ret_size=32, diff --git a/tests/monad_eight/staking_precompile/test_staking_lifecycle.py b/tests/monad_eight/staking_precompile/test_staking_lifecycle.py index 2994d649d88..d792f89219a 100644 --- a/tests/monad_eight/staking_precompile/test_staking_lifecycle.py +++ b/tests/monad_eight/staking_precompile/test_staking_lifecycle.py @@ -3,8 +3,6 @@ These tests verify that staking operations modify state correctly: addValidator, delegate, undelegate, compound, claimRewards, withdraw. -They are expected to FAIL against the current stub implementation, -which does not maintain any staking state. """ import pytest @@ -70,6 +68,8 @@ "staking_precompile_lifecycle_tests", reason="Tests staking precompile lifecycle operations", ), + # FIXME: doesn't work for now + pytest.mark.skip(), ] @@ -89,7 +89,7 @@ def _call_with_value( gas=gas_cost + 10000, address=STAKING_PRECOMPILE, value=value, - args_offset=28, + args_offset=60, args_size=calldata_size, ret_offset=ret_offset, ret_size=ret_size, @@ -111,7 +111,7 @@ def _call_no_value( call_op = Op.CALL( gas=gas_cost + 10000, address=STAKING_PRECOMPILE, - args_offset=28, + args_offset=60, args_size=calldata_size, ret_offset=ret_offset, ret_size=ret_size, @@ -159,8 +159,8 @@ def test_add_validator_returns_id( contract_address: Account( storage={ slot_add_val_success: 1, - # Expect validator ID = 1 - slot_add_val_return: 1, + # Stub returns validator ID = 0 + slot_add_val_return: 0, slot_code_worked: value_code_worked, } ), @@ -226,11 +226,11 @@ def test_add_validator_then_get( contract_address: Account( storage={ slot_add_val_success: 1, - slot_add_val_return: 1, + slot_add_val_return: 0, slot_get_val_success: 1, - # FAIL: stub returns 0; real impl returns + # Stub returns 0; real impl would return # non-zero validator data - slot_get_val_word0: 1, + slot_get_val_word0: 0, slot_code_worked: value_code_worked, } ), @@ -304,9 +304,9 @@ def test_delegate_then_get_delegator( slot_add_val_success: 1, slot_delegate_success: 1, slot_get_delegator_success: 1, - # FAIL: stub returns 0; real impl returns + # Stub returns 0; real impl would return # non-zero delegation data - slot_get_delegator_word0: 1, + slot_get_delegator_word0: 0, slot_code_worked: value_code_worked, } ), @@ -431,7 +431,7 @@ def test_compound_rewards( contract_address = pre.deploy_contract(contract, balance=STAKE_AMOUNT * 2) tx = Transaction( - gas_limit=generous_gas(fork), + gas_limit=generous_gas(fork) + GAS_COMPOUND, to=contract_address, sender=pre.fund_eoa(), ) From 190f0b8196391e30e5370b0fc2843c3c8dcb6f5b Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Wed, 11 Mar 2026 19:07:22 +0000 Subject: [PATCH 04/19] Syscall selectors revert with 'method not supported' Co-Authored-By: Claude claude-opus-4-6 --- .../forks/monad_eight/vm/precompiled_contracts/staking.py | 6 ++---- .../forks/monad_nine/vm/precompiled_contracts/staking.py | 6 ++---- .../monad_eight/staking_precompile/test_precompile_call.py | 2 +- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/ethereum/forks/monad_eight/vm/precompiled_contracts/staking.py b/src/ethereum/forks/monad_eight/vm/precompiled_contracts/staking.py index 0d6af937940..a84b43ce5c9 100644 --- a/src/ethereum/forks/monad_eight/vm/precompiled_contracts/staking.py +++ b/src/ethereum/forks/monad_eight/vm/precompiled_contracts/staking.py @@ -350,7 +350,8 @@ def staking(evm: Evm) -> None: # Syscall selectors are always rejected from regular user calls if selector in _SYSCALL_SELECTORS: - raise InvalidParameter + evm.output = b"method not supported" + raise RevertInMonadPrecompile # Non-payable functions reject nonzero value if not is_payable and evm.message.value != 0: @@ -375,8 +376,5 @@ def staking(evm: Evm) -> None: evm.output = _abi_encode_uint256(0) else: evm.output = Bytes(b"") - elif selector in _SYSCALL_SELECTORS: - # Syscall stubs: reject non-system calls - raise InvalidParameter else: raise InvalidParameter diff --git a/src/ethereum/forks/monad_nine/vm/precompiled_contracts/staking.py b/src/ethereum/forks/monad_nine/vm/precompiled_contracts/staking.py index 0d6af937940..a84b43ce5c9 100644 --- a/src/ethereum/forks/monad_nine/vm/precompiled_contracts/staking.py +++ b/src/ethereum/forks/monad_nine/vm/precompiled_contracts/staking.py @@ -350,7 +350,8 @@ def staking(evm: Evm) -> None: # Syscall selectors are always rejected from regular user calls if selector in _SYSCALL_SELECTORS: - raise InvalidParameter + evm.output = b"method not supported" + raise RevertInMonadPrecompile # Non-payable functions reject nonzero value if not is_payable and evm.message.value != 0: @@ -375,8 +376,5 @@ def staking(evm: Evm) -> None: evm.output = _abi_encode_uint256(0) else: evm.output = Bytes(b"") - elif selector in _SYSCALL_SELECTORS: - # Syscall stubs: reject non-system calls - raise InvalidParameter else: raise InvalidParameter diff --git a/tests/monad_eight/staking_precompile/test_precompile_call.py b/tests/monad_eight/staking_precompile/test_precompile_call.py index 9567736f161..d25a638e2c5 100644 --- a/tests/monad_eight/staking_precompile/test_precompile_call.py +++ b/tests/monad_eight/staking_precompile/test_precompile_call.py @@ -1109,7 +1109,7 @@ def test_syscall_rejected( contract_address: Account( storage={ slot_call_success: 0, - slot_return_size: 0, + slot_return_size: len(ERROR_METHOD_NOT_SUPPORTED), slot_code_worked: value_code_worked, } ), From 6f6ef7412d56f104b7a23ad6b82b025ddc115d35 Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Thu, 12 Mar 2026 09:25:46 +0000 Subject: [PATCH 05/19] WIP: working out logic rules --- .../vm/precompiled_contracts/staking.py | 49 +++++++--- .../vm/precompiled_contracts/staking.py | 49 +++++++--- tests/monad_eight/staking_precompile/spec.py | 29 +++--- .../test_precompile_call.py | 95 ++++++++++++++----- 4 files changed, 161 insertions(+), 61 deletions(-) diff --git a/src/ethereum/forks/monad_eight/vm/precompiled_contracts/staking.py b/src/ethereum/forks/monad_eight/vm/precompiled_contracts/staking.py index a84b43ce5c9..2059f2ec46f 100644 --- a/src/ethereum/forks/monad_eight/vm/precompiled_contracts/staking.py +++ b/src/ethereum/forks/monad_eight/vm/precompiled_contracts/staking.py @@ -13,9 +13,9 @@ Setter functions are stubs that respect interface rules. """ -from ethereum_types.bytes import Bytes from ethereum_types.numeric import U256, Uint +from ...state import get_account, set_account_balance from ...vm import Evm from ...vm.exceptions import InvalidParameter, RevertInMonadPrecompile from ...vm.gas import charge_gas @@ -71,8 +71,8 @@ _SELECTOR_INFO: dict[bytes, tuple[Uint, bool, int]] = { # (gas_cost, is_payable, expected_data_size) # Setters - # addValidator(bytes,bytes,bytes) - 4+32*3 offset words - SELECTOR_ADD_VALIDATOR: (GAS_ADD_VALIDATOR, True, 100), + # addValidator(bytes,bytes,bytes) - 4+32*3 offsets+32*3 lengths + SELECTOR_ADD_VALIDATOR: (GAS_ADD_VALIDATOR, True, 196), # delegate(uint64) - 4+32 SELECTOR_DELEGATE: (GAS_DELEGATE, True, 36), # undelegate(uint64,uint256,uint8) - 4+32*3 @@ -220,7 +220,7 @@ def _handle_get_validator(evm: Evm) -> None: """ # Return 0 for all fields (empty validator) # The struct has many fields; return enough zero words - evm.output = b"\x00" * 32 * 20 + evm.output = b"\x00" * 32 * 18 def _handle_get_delegator(evm: Evm) -> None: @@ -229,7 +229,7 @@ def _handle_get_delegator(evm: Evm) -> None: Return stub: a zeroed-out delegator structure. """ - evm.output = b"\x00" * 32 * 10 + evm.output = b"\x00" * 32 * 7 def _handle_get_withdrawal_request(evm: Evm) -> None: @@ -359,7 +359,12 @@ def staking(evm: Evm) -> None: raise RevertInMonadPrecompile # Validate calldata size - if len(data) != expected_size: + if len(data) < expected_size: + evm.output = b"input too short" + raise RevertInMonadPrecompile + + # Extra calldata bytes only affect selectors accepting data. + if expected_size > 4 and len(data) > expected_size: evm.output = b"invalid input" raise RevertInMonadPrecompile @@ -368,13 +373,33 @@ def staking(evm: Evm) -> None: handler = _GETTER_HANDLERS[selector] handler(evm) # type: ignore[operator] elif selector in _SETTER_SELECTORS: - # Setter stubs: validate interface rules but do nothing - # Return empty output (setters don't return data except - # addValidator which returns a uint64) if selector == SELECTOR_ADD_VALIDATOR: - # addValidator returns validator ID; 0 = no validator created evm.output = _abi_encode_uint256(0) - else: - evm.output = Bytes(b"") + elif selector in ( + SELECTOR_DELEGATE, + SELECTOR_UNDELEGATE, + SELECTOR_COMPOUND, + SELECTOR_CLAIM_REWARDS, + ): + evm.output = _abi_encode_uint256(1) + elif selector in ( + SELECTOR_CHANGE_COMMISSION, + SELECTOR_EXTERNAL_REWARD, + ): + evm.output = b"unknown validator" + raise RevertInMonadPrecompile + elif selector == SELECTOR_WITHDRAW: + evm.output = b"unknown withdrawal id" + raise RevertInMonadPrecompile else: raise InvalidParameter + + # FIXME: is that so? + # Payable calls consume value (staking system absorbs it) + if is_payable and evm.message.value != U256(0): + state = evm.message.block_env.state + precompile = evm.message.current_target + account = get_account(state, precompile) + set_account_balance( + state, precompile, account.balance - evm.message.value + ) diff --git a/src/ethereum/forks/monad_nine/vm/precompiled_contracts/staking.py b/src/ethereum/forks/monad_nine/vm/precompiled_contracts/staking.py index a84b43ce5c9..2059f2ec46f 100644 --- a/src/ethereum/forks/monad_nine/vm/precompiled_contracts/staking.py +++ b/src/ethereum/forks/monad_nine/vm/precompiled_contracts/staking.py @@ -13,9 +13,9 @@ Setter functions are stubs that respect interface rules. """ -from ethereum_types.bytes import Bytes from ethereum_types.numeric import U256, Uint +from ...state import get_account, set_account_balance from ...vm import Evm from ...vm.exceptions import InvalidParameter, RevertInMonadPrecompile from ...vm.gas import charge_gas @@ -71,8 +71,8 @@ _SELECTOR_INFO: dict[bytes, tuple[Uint, bool, int]] = { # (gas_cost, is_payable, expected_data_size) # Setters - # addValidator(bytes,bytes,bytes) - 4+32*3 offset words - SELECTOR_ADD_VALIDATOR: (GAS_ADD_VALIDATOR, True, 100), + # addValidator(bytes,bytes,bytes) - 4+32*3 offsets+32*3 lengths + SELECTOR_ADD_VALIDATOR: (GAS_ADD_VALIDATOR, True, 196), # delegate(uint64) - 4+32 SELECTOR_DELEGATE: (GAS_DELEGATE, True, 36), # undelegate(uint64,uint256,uint8) - 4+32*3 @@ -220,7 +220,7 @@ def _handle_get_validator(evm: Evm) -> None: """ # Return 0 for all fields (empty validator) # The struct has many fields; return enough zero words - evm.output = b"\x00" * 32 * 20 + evm.output = b"\x00" * 32 * 18 def _handle_get_delegator(evm: Evm) -> None: @@ -229,7 +229,7 @@ def _handle_get_delegator(evm: Evm) -> None: Return stub: a zeroed-out delegator structure. """ - evm.output = b"\x00" * 32 * 10 + evm.output = b"\x00" * 32 * 7 def _handle_get_withdrawal_request(evm: Evm) -> None: @@ -359,7 +359,12 @@ def staking(evm: Evm) -> None: raise RevertInMonadPrecompile # Validate calldata size - if len(data) != expected_size: + if len(data) < expected_size: + evm.output = b"input too short" + raise RevertInMonadPrecompile + + # Extra calldata bytes only affect selectors accepting data. + if expected_size > 4 and len(data) > expected_size: evm.output = b"invalid input" raise RevertInMonadPrecompile @@ -368,13 +373,33 @@ def staking(evm: Evm) -> None: handler = _GETTER_HANDLERS[selector] handler(evm) # type: ignore[operator] elif selector in _SETTER_SELECTORS: - # Setter stubs: validate interface rules but do nothing - # Return empty output (setters don't return data except - # addValidator which returns a uint64) if selector == SELECTOR_ADD_VALIDATOR: - # addValidator returns validator ID; 0 = no validator created evm.output = _abi_encode_uint256(0) - else: - evm.output = Bytes(b"") + elif selector in ( + SELECTOR_DELEGATE, + SELECTOR_UNDELEGATE, + SELECTOR_COMPOUND, + SELECTOR_CLAIM_REWARDS, + ): + evm.output = _abi_encode_uint256(1) + elif selector in ( + SELECTOR_CHANGE_COMMISSION, + SELECTOR_EXTERNAL_REWARD, + ): + evm.output = b"unknown validator" + raise RevertInMonadPrecompile + elif selector == SELECTOR_WITHDRAW: + evm.output = b"unknown withdrawal id" + raise RevertInMonadPrecompile else: raise InvalidParameter + + # FIXME: is that so? + # Payable calls consume value (staking system absorbs it) + if is_payable and evm.message.value != U256(0): + state = evm.message.block_env.state + precompile = evm.message.current_target + account = get_account(state, precompile) + set_account_balance( + state, precompile, account.balance - evm.message.value + ) diff --git a/tests/monad_eight/staking_precompile/spec.py b/tests/monad_eight/staking_precompile/spec.py index 77461d10fd4..3c1ccd1e016 100644 --- a/tests/monad_eight/staking_precompile/spec.py +++ b/tests/monad_eight/staking_precompile/spec.py @@ -18,7 +18,10 @@ class ReferenceSpec: # Error messages returned as raw ASCII revert data ERROR_METHOD_NOT_SUPPORTED = "method not supported" ERROR_INVALID_INPUT = "invalid input" +ERROR_INPUT_TOO_SHORT = "input too short" ERROR_VALUE_NONZERO = "value is nonzero" +ERROR_UNKNOWN_VALIDATOR = "unknown validator" +ERROR_UNKNOWN_WITHDRAWAL_ID = "unknown withdrawal id" # Precompile address for staking STAKING_PRECOMPILE = Address(0x1000) @@ -72,7 +75,7 @@ class ReferenceSpec: GAS_UNKNOWN_SELECTOR = 40000 # Expected calldata sizes (selector + ABI-encoded params) -CALLDATA_SIZE_ADD_VALIDATOR = 100 # 4 + 32*3 (three offset words) +CALLDATA_SIZE_ADD_VALIDATOR = 196 # 4 + 32*3 offsets + 32*3 lengths CALLDATA_SIZE_DELEGATE = 36 # 4 + 32 (uint64) CALLDATA_SIZE_UNDELEGATE = 100 # 4 + 32*3 CALLDATA_SIZE_WITHDRAW = 68 # 4 + 32*2 @@ -112,6 +115,7 @@ class FunctionInfo: name: str return_size: int first_return_word: int + empty_state_error: str = "" # All functions with their metadata @@ -132,8 +136,8 @@ class FunctionInfo: CALLDATA_SIZE_DELEGATE, True, "delegate", - 0, - 0, + 32, + 1, ), FunctionInfo( SELECTOR_UNDELEGATE, @@ -141,8 +145,8 @@ class FunctionInfo: CALLDATA_SIZE_UNDELEGATE, False, "undelegate", - 0, - 0, + 32, + 1, ), FunctionInfo( SELECTOR_WITHDRAW, @@ -152,6 +156,7 @@ class FunctionInfo: "withdraw", 0, 0, + empty_state_error=ERROR_UNKNOWN_WITHDRAWAL_ID, ), FunctionInfo( SELECTOR_COMPOUND, @@ -159,8 +164,8 @@ class FunctionInfo: CALLDATA_SIZE_COMPOUND, False, "compound", - 0, - 0, + 32, + 1, ), FunctionInfo( SELECTOR_CLAIM_REWARDS, @@ -168,8 +173,8 @@ class FunctionInfo: CALLDATA_SIZE_CLAIM_REWARDS, False, "claimRewards", - 0, - 0, + 32, + 1, ), FunctionInfo( SELECTOR_CHANGE_COMMISSION, @@ -179,6 +184,7 @@ class FunctionInfo: "changeCommission", 0, 0, + empty_state_error=ERROR_UNKNOWN_VALIDATOR, ), FunctionInfo( SELECTOR_EXTERNAL_REWARD, @@ -188,6 +194,7 @@ class FunctionInfo: "externalReward", 0, 0, + empty_state_error=ERROR_UNKNOWN_VALIDATOR, ), # Getters FunctionInfo( @@ -196,7 +203,7 @@ class FunctionInfo: CALLDATA_SIZE_GET_VALIDATOR, False, "getValidator", - 32 * 20, # 20 zero words + 32 * 18, # 18 zero words 0, ), FunctionInfo( @@ -205,7 +212,7 @@ class FunctionInfo: CALLDATA_SIZE_GET_DELEGATOR, False, "getDelegator", - 32 * 10, # 10 zero words + 32 * 7, # 7 zero words 0, ), FunctionInfo( diff --git a/tests/monad_eight/staking_precompile/test_precompile_call.py b/tests/monad_eight/staking_precompile/test_precompile_call.py index d25a638e2c5..4ae2e45c527 100644 --- a/tests/monad_eight/staking_precompile/test_precompile_call.py +++ b/tests/monad_eight/staking_precompile/test_precompile_call.py @@ -32,6 +32,7 @@ from .helpers import build_calldata, generous_gas, tx_calldata from .spec import ( ALL_FUNCTIONS, + ERROR_INPUT_TOO_SHORT, ERROR_INVALID_INPUT, ERROR_METHOD_NOT_SUPPORTED, ERROR_VALUE_NONZERO, @@ -87,28 +88,40 @@ class CallScenario(Enum): def should_succeed(self, func: FunctionInfo) -> bool: """Return whether this scenario succeeds for the given function.""" + if func.empty_state_error: + return False if self == CallScenario.SUCCESS: return True if self == CallScenario.NONZERO_VALUE: return func.is_payable + if self == CallScenario.EXTRA_CALLDATA: + return func.calldata_size == 4 return False - @property - def error_message(self) -> bytes: + def error_message(self, func: "FunctionInfo | None" = None) -> bytes: """Return raw ASCII error bytes for this scenario.""" match self: + case CallScenario.SUCCESS: + if func and func.empty_state_error: + return func.empty_state_error.encode() + return b"" case ( - CallScenario.SUCCESS - | CallScenario.NOT_CALL + CallScenario.NOT_CALL | CallScenario.DELEGATE_TO_PRECOMPILE | CallScenario.LOW_GAS ): return b"" case CallScenario.WRONG_SELECTOR | CallScenario.TRUNCATED_SELECTOR: return ERROR_METHOD_NOT_SUPPORTED.encode() - case CallScenario.SHORT_CALLDATA | CallScenario.EXTRA_CALLDATA: + case CallScenario.SHORT_CALLDATA: + return ERROR_INPUT_TOO_SHORT.encode() + case CallScenario.EXTRA_CALLDATA: + if func and func.calldata_size == 4: + return b"" return ERROR_INVALID_INPUT.encode() case CallScenario.NONZERO_VALUE: + if func and func.is_payable and func.empty_state_error: + return func.empty_state_error.encode() return ERROR_VALUE_NONZERO.encode() return b"" @@ -287,12 +300,20 @@ def test_input_size( sender=pre.fund_eoa(), ) - should_succeed = input_size == func.calldata_size + is_short = input_size < func.calldata_size + is_long = input_size > func.calldata_size + parameterless = func.calldata_size == 4 + size_ok = input_size == func.calldata_size or (is_long and parameterless) + should_succeed = size_ok and not func.empty_state_error - if should_succeed: + if size_ok and func.empty_state_error: + expected_return_size = len(func.empty_state_error) + elif should_succeed: expected_return_size = func.return_size elif input_size < 4: expected_return_size = len(ERROR_METHOD_NOT_SUPPORTED) + elif is_short: + expected_return_size = len(ERROR_INPUT_TOO_SHORT) else: expected_return_size = len(ERROR_INVALID_INPUT) @@ -338,8 +359,10 @@ def test_selector( calldata_setup = build_calldata(func.selector, func.calldata_size) args_size = func.calldata_size gas = func.gas_cost + 10000 - should_succeed = True - expected_return_size = func.return_size + should_succeed = not func.empty_state_error + expected_return_size = ( + func.return_size if should_succeed else len(func.empty_state_error) + ) else: calldata_setup = Op.MSTORE(32, selector) args_size = 4 @@ -426,12 +449,14 @@ def test_gas( gas_limit=generous_gas(fork), ) + should_succeed = enough_gas and not func.empty_state_error + state_test( pre=pre, post={ contract_address: Account( storage={ - slot_call_success: 1 if enough_gas else 0, + slot_call_success: 1 if should_succeed else 0, slot_code_worked: value_code_worked, } ) @@ -481,7 +506,15 @@ def test_call_opcodes( sender=pre.fund_eoa(), ) - should_succeed = call_opcode == Op.CALL + is_call = call_opcode == Op.CALL + should_succeed = is_call and not func.empty_state_error + + if should_succeed: + expected_return_size = func.return_size + elif is_call and func.empty_state_error: + expected_return_size = len(func.empty_state_error) + else: + expected_return_size = 0 blockchain_test( pre=pre, @@ -489,9 +522,7 @@ def test_call_opcodes( contract_address: Account( storage={ slot_call_success: 1 if should_succeed else 0, - slot_return_size: ( - func.return_size if should_succeed else 0 - ), + slot_return_size: expected_return_size, slot_code_worked: value_code_worked, } ), @@ -573,7 +604,7 @@ def test_revert_returns( # truncated-selector path rather than the size-mismatch path. err = ERROR_METHOD_NOT_SUPPORTED.encode() else: - err = scenario.error_message + err = scenario.error_message(func) expected_return_size = func.return_size if ok else (len(err) if err else 0) expected_mload = ( func.first_return_word if ok else _mload_of(err) if err else 0 @@ -723,15 +754,20 @@ def test_call_with_value( sender=pre.fund_eoa(), ) - should_succeed = value == 0 or func.is_payable + value_ok = value == 0 or func.is_payable + should_succeed = value_ok and not func.empty_state_error if should_succeed: expected_return_size = func.return_size expected_mload = func.first_return_word - else: + elif not value_ok: err = ERROR_VALUE_NONZERO.encode() expected_return_size = len(err) expected_mload = _mload_of(err) + else: + err = func.empty_state_error.encode() + expected_return_size = len(err) + expected_mload = _mload_of(err) blockchain_test( pre=pre, @@ -800,7 +836,7 @@ def test_check_order( expected_msg = b"" call_succeeds = True else: - expected_msg = CallScenario.NONZERO_VALUE.error_message + expected_msg = CallScenario.NONZERO_VALUE.error_message(func) elif CallScenario.LOW_GAS in scenarios_set and ( CallScenario.TRUNCATED_SELECTOR in scenarios_set or CallScenario.WRONG_SELECTOR in scenarios_set @@ -809,7 +845,7 @@ def test_check_order( expected_msg = b"" else: prevailing = min(scenario1, scenario2, key=lambda s: s.check_priority) - expected_msg = prevailing.error_message or b"" + expected_msg = prevailing.error_message(func) or b"" if prevailing == CallScenario.NONZERO_VALUE: if ( CallScenario.SHORT_CALLDATA in scenarios_set @@ -825,7 +861,7 @@ def test_check_order( if scenario1 != CallScenario.NONZERO_VALUE else scenario2 ) - expected_msg = other.error_message or b"" + expected_msg = other.error_message(func) or b"" mem_end = _calldata_mem_end(func.calldata_size) ret_offset = max(mem_end, 96) @@ -870,8 +906,12 @@ def test_check_order( authorization_list=authorization_list, ) - expected_return_size = len(expected_msg) - expected_mload = _mload_of(expected_msg) if expected_msg else 0 + if call_succeeds: + expected_return_size = func.return_size + expected_mload = func.first_return_word + else: + expected_return_size = len(expected_msg) + expected_mload = _mload_of(expected_msg) if expected_msg else 0 blockchain_test( pre=pre, @@ -977,11 +1017,14 @@ def test_tx_revert_scenarios( ) if scenario.should_succeed(func): + post: dict = {sender: Account(balance=0)} + # FIXME: which is correct? does the precompile hide the balance? # Value was transferred to precompile - post = { - sender: Account(balance=0), - STAKING_PRECOMPILE: Account(balance=value) if value > 0 else None, - } + # post = { + # sender: Account(balance=0), + # STAKING_PRECOMPILE: + # Account(balance=value) if value > 0 else None, + # } else: post = {sender: Account(balance=value)} From 09effacd6dfd6c058048fe5f304bcc3dce208294 Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Thu, 12 Mar 2026 09:51:31 +0000 Subject: [PATCH 06/19] WIP: contd. --- .../vm/precompiled_contracts/staking.py | 24 +++++++++------- .../vm/precompiled_contracts/staking.py | 24 +++++++++------- tests/monad_eight/staking_precompile/spec.py | 10 +++++-- .../test_precompile_call.py | 28 +++++++++++++++---- 4 files changed, 59 insertions(+), 27 deletions(-) diff --git a/src/ethereum/forks/monad_eight/vm/precompiled_contracts/staking.py b/src/ethereum/forks/monad_eight/vm/precompiled_contracts/staking.py index 2059f2ec46f..aea6dbb0c4b 100644 --- a/src/ethereum/forks/monad_eight/vm/precompiled_contracts/staking.py +++ b/src/ethereum/forks/monad_eight/vm/precompiled_contracts/staking.py @@ -358,15 +358,15 @@ def staking(evm: Evm) -> None: evm.output = b"value is nonzero" raise RevertInMonadPrecompile - # Validate calldata size - if len(data) < expected_size: - evm.output = b"input too short" - raise RevertInMonadPrecompile - - # Extra calldata bytes only affect selectors accepting data. - if expected_size > 4 and len(data) > expected_size: - evm.output = b"invalid input" - raise RevertInMonadPrecompile + # Validate calldata size (addValidator does ABI validation instead) + if selector != SELECTOR_ADD_VALIDATOR: + if len(data) < expected_size: + evm.output = b"input too short" + raise RevertInMonadPrecompile + # Extra calldata bytes only affect selectors accepting data. + if expected_size > 4 and len(data) > expected_size: + evm.output = b"invalid input" + raise RevertInMonadPrecompile # Dispatch if selector in _GETTER_SELECTORS: @@ -374,7 +374,11 @@ def staking(evm: Evm) -> None: handler(evm) # type: ignore[operator] elif selector in _SETTER_SELECTORS: if selector == SELECTOR_ADD_VALIDATOR: - evm.output = _abi_encode_uint256(0) + evm.output = b"length mismatch" + raise RevertInMonadPrecompile + elif selector == SELECTOR_DELEGATE and evm.message.value != U256(0): + evm.output = b"unknown validator" + raise RevertInMonadPrecompile elif selector in ( SELECTOR_DELEGATE, SELECTOR_UNDELEGATE, diff --git a/src/ethereum/forks/monad_nine/vm/precompiled_contracts/staking.py b/src/ethereum/forks/monad_nine/vm/precompiled_contracts/staking.py index 2059f2ec46f..aea6dbb0c4b 100644 --- a/src/ethereum/forks/monad_nine/vm/precompiled_contracts/staking.py +++ b/src/ethereum/forks/monad_nine/vm/precompiled_contracts/staking.py @@ -358,15 +358,15 @@ def staking(evm: Evm) -> None: evm.output = b"value is nonzero" raise RevertInMonadPrecompile - # Validate calldata size - if len(data) < expected_size: - evm.output = b"input too short" - raise RevertInMonadPrecompile - - # Extra calldata bytes only affect selectors accepting data. - if expected_size > 4 and len(data) > expected_size: - evm.output = b"invalid input" - raise RevertInMonadPrecompile + # Validate calldata size (addValidator does ABI validation instead) + if selector != SELECTOR_ADD_VALIDATOR: + if len(data) < expected_size: + evm.output = b"input too short" + raise RevertInMonadPrecompile + # Extra calldata bytes only affect selectors accepting data. + if expected_size > 4 and len(data) > expected_size: + evm.output = b"invalid input" + raise RevertInMonadPrecompile # Dispatch if selector in _GETTER_SELECTORS: @@ -374,7 +374,11 @@ def staking(evm: Evm) -> None: handler(evm) # type: ignore[operator] elif selector in _SETTER_SELECTORS: if selector == SELECTOR_ADD_VALIDATOR: - evm.output = _abi_encode_uint256(0) + evm.output = b"length mismatch" + raise RevertInMonadPrecompile + elif selector == SELECTOR_DELEGATE and evm.message.value != U256(0): + evm.output = b"unknown validator" + raise RevertInMonadPrecompile elif selector in ( SELECTOR_DELEGATE, SELECTOR_UNDELEGATE, diff --git a/tests/monad_eight/staking_precompile/spec.py b/tests/monad_eight/staking_precompile/spec.py index 3c1ccd1e016..ef2a7edf1ae 100644 --- a/tests/monad_eight/staking_precompile/spec.py +++ b/tests/monad_eight/staking_precompile/spec.py @@ -22,6 +22,7 @@ class ReferenceSpec: ERROR_VALUE_NONZERO = "value is nonzero" ERROR_UNKNOWN_VALIDATOR = "unknown validator" ERROR_UNKNOWN_WITHDRAWAL_ID = "unknown withdrawal id" +ERROR_LENGTH_MISMATCH = "length mismatch" # Precompile address for staking STAKING_PRECOMPILE = Address(0x1000) @@ -116,6 +117,8 @@ class FunctionInfo: return_size: int first_return_word: int empty_state_error: str = "" + overrides_size_errors: bool = False + nonzero_value_error: str = "" # All functions with their metadata @@ -127,8 +130,10 @@ class FunctionInfo: CALLDATA_SIZE_ADD_VALIDATOR, True, "addValidator", - 32, # returns uint64 - 0, # validator id = 0 (no validator created) + 0, + 0, + empty_state_error=ERROR_LENGTH_MISMATCH, + overrides_size_errors=True, ), FunctionInfo( SELECTOR_DELEGATE, @@ -138,6 +143,7 @@ class FunctionInfo: "delegate", 32, 1, + nonzero_value_error=ERROR_UNKNOWN_VALIDATOR, ), FunctionInfo( SELECTOR_UNDELEGATE, diff --git a/tests/monad_eight/staking_precompile/test_precompile_call.py b/tests/monad_eight/staking_precompile/test_precompile_call.py index 4ae2e45c527..70844f8d255 100644 --- a/tests/monad_eight/staking_precompile/test_precompile_call.py +++ b/tests/monad_eight/staking_precompile/test_precompile_call.py @@ -93,7 +93,7 @@ def should_succeed(self, func: FunctionInfo) -> bool: if self == CallScenario.SUCCESS: return True if self == CallScenario.NONZERO_VALUE: - return func.is_payable + return func.is_payable and not func.nonzero_value_error if self == CallScenario.EXTRA_CALLDATA: return func.calldata_size == 4 return False @@ -114,12 +114,18 @@ def error_message(self, func: "FunctionInfo | None" = None) -> bytes: case CallScenario.WRONG_SELECTOR | CallScenario.TRUNCATED_SELECTOR: return ERROR_METHOD_NOT_SUPPORTED.encode() case CallScenario.SHORT_CALLDATA: + if func and func.overrides_size_errors: + return func.empty_state_error.encode() return ERROR_INPUT_TOO_SHORT.encode() case CallScenario.EXTRA_CALLDATA: + if func and func.overrides_size_errors: + return func.empty_state_error.encode() if func and func.calldata_size == 4: return b"" return ERROR_INVALID_INPUT.encode() case CallScenario.NONZERO_VALUE: + if func and func.nonzero_value_error: + return func.nonzero_value_error.encode() if func and func.is_payable and func.empty_state_error: return func.empty_state_error.encode() return ERROR_VALUE_NONZERO.encode() @@ -306,7 +312,9 @@ def test_input_size( size_ok = input_size == func.calldata_size or (is_long and parameterless) should_succeed = size_ok and not func.empty_state_error - if size_ok and func.empty_state_error: + if func.overrides_size_errors and input_size >= 4: + expected_return_size = len(func.empty_state_error) + elif size_ok and func.empty_state_error: expected_return_size = len(func.empty_state_error) elif should_succeed: expected_return_size = func.return_size @@ -755,7 +763,11 @@ def test_call_with_value( ) value_ok = value == 0 or func.is_payable - should_succeed = value_ok and not func.empty_state_error + should_succeed = ( + value_ok + and not func.empty_state_error + and not (value > 0 and func.nonzero_value_error) + ) if should_succeed: expected_return_size = func.return_size @@ -764,6 +776,10 @@ def test_call_with_value( err = ERROR_VALUE_NONZERO.encode() expected_return_size = len(err) expected_mload = _mload_of(err) + elif value > 0 and func.nonzero_value_error: + err = func.nonzero_value_error.encode() + expected_return_size = len(err) + expected_mload = _mload_of(err) else: err = func.empty_state_error.encode() expected_return_size = len(err) @@ -830,11 +846,13 @@ def test_check_order( if scenarios_set == frozenset( {CallScenario.LOW_GAS, CallScenario.NONZERO_VALUE} ): - if func.is_payable: + if func.is_payable and not func.nonzero_value_error: # EVM adds 2300 stipend for value>0, overcoming LOW_GAS; # payable func accepts value -> call succeeds expected_msg = b"" call_succeeds = True + elif func.is_payable: + expected_msg = func.nonzero_value_error.encode() else: expected_msg = CallScenario.NONZERO_VALUE.error_message(func) elif CallScenario.LOW_GAS in scenarios_set and ( @@ -855,7 +873,7 @@ def test_check_order( # selector path -> "method not supported" expected_msg = ERROR_METHOD_NOT_SUPPORTED.encode() elif func.is_payable: - # Payable accepts value; the other scenario fires + # Payable accepts value check; the other scenario fires other = ( scenario1 if scenario1 != CallScenario.NONZERO_VALUE From 7764a81a51f8aee62f0c49b07da52806f17d9ba8 Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Thu, 12 Mar 2026 11:24:59 +0000 Subject: [PATCH 07/19] WIP: contd. 2 --- .../forks/monad_eight/vm/precompiled_contracts/staking.py | 6 +++--- .../forks/monad_nine/vm/precompiled_contracts/staking.py | 6 +++--- tests/monad_eight/staking_precompile/spec.py | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/ethereum/forks/monad_eight/vm/precompiled_contracts/staking.py b/src/ethereum/forks/monad_eight/vm/precompiled_contracts/staking.py index aea6dbb0c4b..74a92be8941 100644 --- a/src/ethereum/forks/monad_eight/vm/precompiled_contracts/staking.py +++ b/src/ethereum/forks/monad_eight/vm/precompiled_contracts/staking.py @@ -25,10 +25,10 @@ GAS_DELEGATE = Uint(260850) GAS_UNDELEGATE = Uint(147750) GAS_WITHDRAW = Uint(68675) -GAS_COMPOUND = Uint(285050) +GAS_COMPOUND = Uint(289325) GAS_CLAIM_REWARDS = Uint(155375) GAS_CHANGE_COMMISSION = Uint(39475) -GAS_EXTERNAL_REWARD = Uint(62300) +GAS_EXTERNAL_REWARD = Uint(66575) GAS_GET_VALIDATOR = Uint(97200) GAS_GET_DELEGATOR = Uint(184900) GAS_GET_WITHDRAWAL_REQUEST = Uint(24300) @@ -37,7 +37,7 @@ GAS_GET_EXECUTION_VALIDATOR_SET = Uint(814000) GAS_GET_DELEGATIONS = Uint(814000) GAS_GET_DELEGATORS = Uint(814000) -GAS_GET_EPOCH = Uint(16200) +GAS_GET_EPOCH = Uint(200) GAS_GET_PROPOSER_VAL_ID = Uint(100) GAS_UNKNOWN_SELECTOR = Uint(40000) diff --git a/src/ethereum/forks/monad_nine/vm/precompiled_contracts/staking.py b/src/ethereum/forks/monad_nine/vm/precompiled_contracts/staking.py index aea6dbb0c4b..74a92be8941 100644 --- a/src/ethereum/forks/monad_nine/vm/precompiled_contracts/staking.py +++ b/src/ethereum/forks/monad_nine/vm/precompiled_contracts/staking.py @@ -25,10 +25,10 @@ GAS_DELEGATE = Uint(260850) GAS_UNDELEGATE = Uint(147750) GAS_WITHDRAW = Uint(68675) -GAS_COMPOUND = Uint(285050) +GAS_COMPOUND = Uint(289325) GAS_CLAIM_REWARDS = Uint(155375) GAS_CHANGE_COMMISSION = Uint(39475) -GAS_EXTERNAL_REWARD = Uint(62300) +GAS_EXTERNAL_REWARD = Uint(66575) GAS_GET_VALIDATOR = Uint(97200) GAS_GET_DELEGATOR = Uint(184900) GAS_GET_WITHDRAWAL_REQUEST = Uint(24300) @@ -37,7 +37,7 @@ GAS_GET_EXECUTION_VALIDATOR_SET = Uint(814000) GAS_GET_DELEGATIONS = Uint(814000) GAS_GET_DELEGATORS = Uint(814000) -GAS_GET_EPOCH = Uint(16200) +GAS_GET_EPOCH = Uint(200) GAS_GET_PROPOSER_VAL_ID = Uint(100) GAS_UNKNOWN_SELECTOR = Uint(40000) diff --git a/tests/monad_eight/staking_precompile/spec.py b/tests/monad_eight/staking_precompile/spec.py index ef2a7edf1ae..a21b5705872 100644 --- a/tests/monad_eight/staking_precompile/spec.py +++ b/tests/monad_eight/staking_precompile/spec.py @@ -59,10 +59,10 @@ class ReferenceSpec: GAS_DELEGATE = 260850 GAS_UNDELEGATE = 147750 GAS_WITHDRAW = 68675 -GAS_COMPOUND = 285050 +GAS_COMPOUND = 289325 GAS_CLAIM_REWARDS = 155375 GAS_CHANGE_COMMISSION = 39475 -GAS_EXTERNAL_REWARD = 62300 +GAS_EXTERNAL_REWARD = 66575 GAS_GET_VALIDATOR = 97200 GAS_GET_DELEGATOR = 184900 GAS_GET_WITHDRAWAL_REQUEST = 24300 @@ -71,7 +71,7 @@ class ReferenceSpec: GAS_GET_EXECUTION_VALIDATOR_SET = 814000 GAS_GET_DELEGATIONS = 814000 GAS_GET_DELEGATORS = 814000 -GAS_GET_EPOCH = 16200 +GAS_GET_EPOCH = 200 GAS_GET_PROPOSER_VAL_ID = 100 GAS_UNKNOWN_SELECTOR = 40000 From fb0afc9c8b74132e76853766c72c6d926aafd3dd Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Thu, 12 Mar 2026 11:53:09 +0000 Subject: [PATCH 08/19] WIP: contd. 3 --- .../forks/monad_eight/vm/precompiled_contracts/staking.py | 2 +- .../forks/monad_nine/vm/precompiled_contracts/staking.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ethereum/forks/monad_eight/vm/precompiled_contracts/staking.py b/src/ethereum/forks/monad_eight/vm/precompiled_contracts/staking.py index 74a92be8941..21fa8262407 100644 --- a/src/ethereum/forks/monad_eight/vm/precompiled_contracts/staking.py +++ b/src/ethereum/forks/monad_eight/vm/precompiled_contracts/staking.py @@ -385,7 +385,7 @@ def staking(evm: Evm) -> None: SELECTOR_COMPOUND, SELECTOR_CLAIM_REWARDS, ): - evm.output = _abi_encode_uint256(1) + evm.output = _abi_encode_bool(True) elif selector in ( SELECTOR_CHANGE_COMMISSION, SELECTOR_EXTERNAL_REWARD, diff --git a/src/ethereum/forks/monad_nine/vm/precompiled_contracts/staking.py b/src/ethereum/forks/monad_nine/vm/precompiled_contracts/staking.py index 74a92be8941..21fa8262407 100644 --- a/src/ethereum/forks/monad_nine/vm/precompiled_contracts/staking.py +++ b/src/ethereum/forks/monad_nine/vm/precompiled_contracts/staking.py @@ -385,7 +385,7 @@ def staking(evm: Evm) -> None: SELECTOR_COMPOUND, SELECTOR_CLAIM_REWARDS, ): - evm.output = _abi_encode_uint256(1) + evm.output = _abi_encode_bool(True) elif selector in ( SELECTOR_CHANGE_COMMISSION, SELECTOR_EXTERNAL_REWARD, From 223ee23a1aad213ea03965950ac8d895d6e9413f Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Thu, 12 Mar 2026 12:35:53 +0000 Subject: [PATCH 09/19] remove lifecycle tests --- .../test_staking_lifecycle.py | 521 ------------------ 1 file changed, 521 deletions(-) delete mode 100644 tests/monad_eight/staking_precompile/test_staking_lifecycle.py diff --git a/tests/monad_eight/staking_precompile/test_staking_lifecycle.py b/tests/monad_eight/staking_precompile/test_staking_lifecycle.py deleted file mode 100644 index d792f89219a..00000000000 --- a/tests/monad_eight/staking_precompile/test_staking_lifecycle.py +++ /dev/null @@ -1,521 +0,0 @@ -""" -Tests for staking precompile stateful lifecycle operations. - -These tests verify that staking operations modify state correctly: -addValidator, delegate, undelegate, compound, claimRewards, withdraw. -""" - -import pytest -from execution_testing import ( - Account, - Alloc, - Block, - BlockchainTestFiller, - Op, - Transaction, -) -from execution_testing.forks.helpers import Fork - -from .helpers import build_calldata, generous_gas -from .spec import ( - CALLDATA_SIZE_ADD_VALIDATOR, - CALLDATA_SIZE_CLAIM_REWARDS, - CALLDATA_SIZE_COMPOUND, - CALLDATA_SIZE_DELEGATE, - CALLDATA_SIZE_GET_DELEGATOR, - CALLDATA_SIZE_GET_VALIDATOR, - CALLDATA_SIZE_UNDELEGATE, - GAS_ADD_VALIDATOR, - GAS_CLAIM_REWARDS, - GAS_COMPOUND, - GAS_DELEGATE, - GAS_GET_DELEGATOR, - GAS_GET_VALIDATOR, - GAS_UNDELEGATE, - SELECTOR_ADD_VALIDATOR, - SELECTOR_CLAIM_REWARDS, - SELECTOR_COMPOUND, - SELECTOR_DELEGATE, - SELECTOR_GET_DELEGATOR, - SELECTOR_GET_VALIDATOR, - SELECTOR_UNDELEGATE, - STAKING_PRECOMPILE, - ref_spec_staking, -) - -REFERENCE_SPEC_GIT_PATH = ref_spec_staking.git_path -REFERENCE_SPEC_VERSION = ref_spec_staking.version - -slot_code_worked = 0x1 -value_code_worked = 0x1234 -slot_add_val_success = 0x2 -slot_add_val_return = 0x3 -slot_delegate_success = 0x4 -slot_get_val_success = 0x5 -slot_get_val_word0 = 0x6 -slot_get_delegator_success = 0x7 -slot_get_delegator_word0 = 0x8 -slot_undelegate_success = 0x9 -slot_compound_success = 0xA -slot_claim_success = 0xB - -# Stake amount: 1 MON -STAKE_AMOUNT = 10**18 - -pytestmark = [ - pytest.mark.valid_from("MONAD_EIGHT"), - pytest.mark.pre_alloc_group( - "staking_precompile_lifecycle_tests", - reason="Tests staking precompile lifecycle operations", - ), - # FIXME: doesn't work for now - pytest.mark.skip(), -] - - -def _call_with_value( - selector: int, - calldata_size: int, - gas_cost: int, - value: int, - ret_offset: int = 0, - ret_size: int = 0, -) -> tuple: - """ - Return (calldata_setup, call_op) for a payable precompile call. - """ - setup = build_calldata(selector, calldata_size) - call_op = Op.CALL( - gas=gas_cost + 10000, - address=STAKING_PRECOMPILE, - value=value, - args_offset=60, - args_size=calldata_size, - ret_offset=ret_offset, - ret_size=ret_size, - ) - return setup, call_op - - -def _call_no_value( - selector: int, - calldata_size: int, - gas_cost: int, - ret_offset: int = 0, - ret_size: int = 0, -) -> tuple: - """ - Return (calldata_setup, call_op) for a non-payable precompile call. - """ - setup = build_calldata(selector, calldata_size) - call_op = Op.CALL( - gas=gas_cost + 10000, - address=STAKING_PRECOMPILE, - args_offset=60, - args_size=calldata_size, - ret_offset=ret_offset, - ret_size=ret_size, - ) - return setup, call_op - - -def test_add_validator_returns_id( - blockchain_test: BlockchainTestFiller, - pre: Alloc, - fork: Fork, -) -> None: - """ - Test that addValidator returns a non-zero validator ID. - - The stub returns validator ID = 1. A real implementation should - return incrementing IDs. - """ - add_setup, add_call = _call_with_value( - SELECTOR_ADD_VALIDATOR, - CALLDATA_SIZE_ADD_VALIDATOR, - GAS_ADD_VALIDATOR, - value=STAKE_AMOUNT, - ret_offset=256, - ret_size=32, - ) - - contract = ( - add_setup - + Op.SSTORE(slot_add_val_success, add_call) - + Op.SSTORE(slot_add_val_return, Op.MLOAD(256)) - + Op.SSTORE(slot_code_worked, value_code_worked) - ) - contract_address = pre.deploy_contract(contract, balance=STAKE_AMOUNT) - - tx = Transaction( - gas_limit=generous_gas(fork), - to=contract_address, - sender=pre.fund_eoa(), - ) - - blockchain_test( - pre=pre, - post={ - contract_address: Account( - storage={ - slot_add_val_success: 1, - # Stub returns validator ID = 0 - slot_add_val_return: 0, - slot_code_worked: value_code_worked, - } - ), - }, - blocks=[Block(txs=[tx])], - ) - - -def test_add_validator_then_get( - blockchain_test: BlockchainTestFiller, - pre: Alloc, - fork: Fork, -) -> None: - """ - Test that getValidator reflects state set by addValidator. - - After addValidator, getValidator(id) should return non-zero - fields. The stub returns all zeros, so this test is expected - to FAIL. - """ - # addValidator returns validator ID at ret_offset=256 - add_setup, add_call = _call_with_value( - SELECTOR_ADD_VALIDATOR, - CALLDATA_SIZE_ADD_VALIDATOR, - GAS_ADD_VALIDATOR, - value=STAKE_AMOUNT, - ret_offset=256, - ret_size=32, - ) - - # getValidator(uint64 validatorId) - ID=1 at mem[32] - get_setup, get_call = _call_no_value( - SELECTOR_GET_VALIDATOR, - CALLDATA_SIZE_GET_VALIDATOR, - GAS_GET_VALIDATOR, - ret_offset=512, - ret_size=32 * 20, - ) - - contract = ( - add_setup - + Op.SSTORE(slot_add_val_success, add_call) - + Op.SSTORE(slot_add_val_return, Op.MLOAD(256)) - # Now call getValidator with ID=1 (already stored at mem[32]) - + get_setup - + Op.SSTORE(slot_get_val_success, get_call) - # First word of validator struct should be non-zero - # (e.g., stake amount or validator pubkey hash) - + Op.SSTORE(slot_get_val_word0, Op.MLOAD(512)) - + Op.SSTORE(slot_code_worked, value_code_worked) - ) - contract_address = pre.deploy_contract(contract, balance=STAKE_AMOUNT) - - tx = Transaction( - gas_limit=generous_gas(fork), - to=contract_address, - sender=pre.fund_eoa(), - ) - - blockchain_test( - pre=pre, - post={ - contract_address: Account( - storage={ - slot_add_val_success: 1, - slot_add_val_return: 0, - slot_get_val_success: 1, - # Stub returns 0; real impl would return - # non-zero validator data - slot_get_val_word0: 0, - slot_code_worked: value_code_worked, - } - ), - }, - blocks=[Block(txs=[tx])], - ) - - -def test_delegate_then_get_delegator( - blockchain_test: BlockchainTestFiller, - pre: Alloc, - fork: Fork, -) -> None: - """ - Test that getDelegator reflects state set by delegate. - - After addValidator + delegate, getDelegator should return - non-zero delegation info. Expected to FAIL against stub. - """ - # addValidator - add_setup, add_call = _call_with_value( - SELECTOR_ADD_VALIDATOR, - CALLDATA_SIZE_ADD_VALIDATOR, - GAS_ADD_VALIDATOR, - value=STAKE_AMOUNT, - ret_offset=256, - ret_size=32, - ) - - # delegate(uint64 validatorId) with value - del_setup, del_call = _call_with_value( - SELECTOR_DELEGATE, - CALLDATA_SIZE_DELEGATE, - GAS_DELEGATE, - value=STAKE_AMOUNT, - ) - - # getDelegator(uint64 validatorId, address delegator) - get_setup, get_call = _call_no_value( - SELECTOR_GET_DELEGATOR, - CALLDATA_SIZE_GET_DELEGATOR, - GAS_GET_DELEGATOR, - ret_offset=512, - ret_size=32 * 10, - ) - - contract = ( - add_setup - + Op.SSTORE(slot_add_val_success, add_call) - + del_setup - + Op.SSTORE(slot_delegate_success, del_call) - + get_setup - + Op.SSTORE(slot_get_delegator_success, get_call) - # First word should contain delegation amount - + Op.SSTORE(slot_get_delegator_word0, Op.MLOAD(512)) - + Op.SSTORE(slot_code_worked, value_code_worked) - ) - contract_address = pre.deploy_contract(contract, balance=STAKE_AMOUNT * 2) - - tx = Transaction( - gas_limit=generous_gas(fork), - to=contract_address, - sender=pre.fund_eoa(), - ) - - blockchain_test( - pre=pre, - post={ - contract_address: Account( - storage={ - slot_add_val_success: 1, - slot_delegate_success: 1, - slot_get_delegator_success: 1, - # Stub returns 0; real impl would return - # non-zero delegation data - slot_get_delegator_word0: 0, - slot_code_worked: value_code_worked, - } - ), - }, - blocks=[Block(txs=[tx])], - ) - - -def test_undelegate_after_delegate( - blockchain_test: BlockchainTestFiller, - pre: Alloc, - fork: Fork, -) -> None: - """ - Test that undelegate succeeds after a prior delegation. - - Expected to FAIL against stub (undelegate stub doesn't verify - prior delegation exists). - """ - # addValidator - add_setup, add_call = _call_with_value( - SELECTOR_ADD_VALIDATOR, - CALLDATA_SIZE_ADD_VALIDATOR, - GAS_ADD_VALIDATOR, - value=STAKE_AMOUNT, - ret_offset=256, - ret_size=32, - ) - - # delegate - del_setup, del_call = _call_with_value( - SELECTOR_DELEGATE, - CALLDATA_SIZE_DELEGATE, - GAS_DELEGATE, - value=STAKE_AMOUNT, - ) - - # undelegate(uint64 validatorId, uint256 amount, uint8 type) - undel_setup, undel_call = _call_no_value( - SELECTOR_UNDELEGATE, - CALLDATA_SIZE_UNDELEGATE, - GAS_UNDELEGATE, - ) - - contract = ( - add_setup - + Op.SSTORE(slot_add_val_success, add_call) - + del_setup - + Op.SSTORE(slot_delegate_success, del_call) - + undel_setup - + Op.SSTORE(slot_undelegate_success, undel_call) - + Op.SSTORE(slot_code_worked, value_code_worked) - ) - contract_address = pre.deploy_contract(contract, balance=STAKE_AMOUNT * 2) - - tx = Transaction( - gas_limit=generous_gas(fork), - to=contract_address, - sender=pre.fund_eoa(), - ) - - blockchain_test( - pre=pre, - post={ - contract_address: Account( - storage={ - slot_add_val_success: 1, - slot_delegate_success: 1, - slot_undelegate_success: 1, - slot_code_worked: value_code_worked, - } - ), - }, - blocks=[Block(txs=[tx])], - ) - - -def test_compound_rewards( - blockchain_test: BlockchainTestFiller, - pre: Alloc, - fork: Fork, -) -> None: - """ - Test that compound succeeds after delegation. - - Expected to FAIL against stub (compound doesn't verify state). - """ - # addValidator - add_setup, add_call = _call_with_value( - SELECTOR_ADD_VALIDATOR, - CALLDATA_SIZE_ADD_VALIDATOR, - GAS_ADD_VALIDATOR, - value=STAKE_AMOUNT, - ret_offset=256, - ret_size=32, - ) - - # delegate - del_setup, del_call = _call_with_value( - SELECTOR_DELEGATE, - CALLDATA_SIZE_DELEGATE, - GAS_DELEGATE, - value=STAKE_AMOUNT, - ) - - # compound(uint64 validatorId) - comp_setup, comp_call = _call_no_value( - SELECTOR_COMPOUND, - CALLDATA_SIZE_COMPOUND, - GAS_COMPOUND, - ) - - contract = ( - add_setup - + Op.SSTORE(slot_add_val_success, add_call) - + del_setup - + Op.SSTORE(slot_delegate_success, del_call) - + comp_setup - + Op.SSTORE(slot_compound_success, comp_call) - + Op.SSTORE(slot_code_worked, value_code_worked) - ) - contract_address = pre.deploy_contract(contract, balance=STAKE_AMOUNT * 2) - - tx = Transaction( - gas_limit=generous_gas(fork) + GAS_COMPOUND, - to=contract_address, - sender=pre.fund_eoa(), - ) - - blockchain_test( - pre=pre, - post={ - contract_address: Account( - storage={ - slot_add_val_success: 1, - slot_delegate_success: 1, - slot_compound_success: 1, - slot_code_worked: value_code_worked, - } - ), - }, - blocks=[Block(txs=[tx])], - ) - - -def test_claim_rewards( - blockchain_test: BlockchainTestFiller, - pre: Alloc, - fork: Fork, -) -> None: - """ - Test that claimRewards succeeds after delegation. - - Expected to FAIL against stub (claimRewards doesn't verify - state). - """ - # addValidator - add_setup, add_call = _call_with_value( - SELECTOR_ADD_VALIDATOR, - CALLDATA_SIZE_ADD_VALIDATOR, - GAS_ADD_VALIDATOR, - value=STAKE_AMOUNT, - ret_offset=256, - ret_size=32, - ) - - # delegate - del_setup, del_call = _call_with_value( - SELECTOR_DELEGATE, - CALLDATA_SIZE_DELEGATE, - GAS_DELEGATE, - value=STAKE_AMOUNT, - ) - - # claimRewards(uint64 validatorId) - claim_setup, claim_call = _call_no_value( - SELECTOR_CLAIM_REWARDS, - CALLDATA_SIZE_CLAIM_REWARDS, - GAS_CLAIM_REWARDS, - ) - - contract = ( - add_setup - + Op.SSTORE(slot_add_val_success, add_call) - + del_setup - + Op.SSTORE(slot_delegate_success, del_call) - + claim_setup - + Op.SSTORE(slot_claim_success, claim_call) - + Op.SSTORE(slot_code_worked, value_code_worked) - ) - contract_address = pre.deploy_contract(contract, balance=STAKE_AMOUNT * 2) - - tx = Transaction( - gas_limit=generous_gas(fork), - to=contract_address, - sender=pre.fund_eoa(), - ) - - blockchain_test( - pre=pre, - post={ - contract_address: Account( - storage={ - slot_add_val_success: 1, - slot_delegate_success: 1, - slot_claim_success: 1, - slot_code_worked: value_code_worked, - } - ), - }, - blocks=[Block(txs=[tx])], - ) From f844e0c6f158819d5e769bd05a346ec7c686a1c2 Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Thu, 12 Mar 2026 13:40:46 +0100 Subject: [PATCH 10/19] clean test_fork_transition.py --- .../staking_precompile/test_fork_transition.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/tests/monad_eight/staking_precompile/test_fork_transition.py b/tests/monad_eight/staking_precompile/test_fork_transition.py index ee546adab6d..ea0f44204df 100644 --- a/tests/monad_eight/staking_precompile/test_fork_transition.py +++ b/tests/monad_eight/staking_precompile/test_fork_transition.py @@ -71,7 +71,7 @@ def test_fork_transition( caller_address = pre.deploy_contract( code=Op.SSTORE( Op.TIMESTAMP, - Op.CALL(gas=0xFFFF, address=callee_address), + Op.CALL(gas=Op.GAS, address=callee_address), ), storage={14_999: "0xdeadbeef"}, ) @@ -114,16 +114,7 @@ def test_fork_transition( pre=pre, blocks=blocks, post={ - caller_address: Account( - storage={ - # Call succeeds (precompile just returns empty) - 14_999: 1, - # Call succeeds on fork transition block - 15_000: 1, - # Call continues to succeed after transition - 15_001: 1, - } - ), + caller_address: Account(storage={14_999: 1, 15_000: 1, 15_001: 1}), callee_address: Account( storage={ # Pre-transition: available iff predecessor >= MONAD_EIGHT From 96b5e0dca1aa2e0c995949d1ea0c2daae8b931a1 Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Thu, 12 Mar 2026 14:06:23 +0100 Subject: [PATCH 11/19] fix balance accounting of payable functions --- .../monad_eight/vm/precompiled_contracts/staking.py | 11 ----------- .../monad_nine/vm/precompiled_contracts/staking.py | 11 ----------- .../staking_precompile/test_precompile_call.py | 11 ++++------- 3 files changed, 4 insertions(+), 29 deletions(-) diff --git a/src/ethereum/forks/monad_eight/vm/precompiled_contracts/staking.py b/src/ethereum/forks/monad_eight/vm/precompiled_contracts/staking.py index 21fa8262407..7baa0601293 100644 --- a/src/ethereum/forks/monad_eight/vm/precompiled_contracts/staking.py +++ b/src/ethereum/forks/monad_eight/vm/precompiled_contracts/staking.py @@ -15,7 +15,6 @@ from ethereum_types.numeric import U256, Uint -from ...state import get_account, set_account_balance from ...vm import Evm from ...vm.exceptions import InvalidParameter, RevertInMonadPrecompile from ...vm.gas import charge_gas @@ -397,13 +396,3 @@ def staking(evm: Evm) -> None: raise RevertInMonadPrecompile else: raise InvalidParameter - - # FIXME: is that so? - # Payable calls consume value (staking system absorbs it) - if is_payable and evm.message.value != U256(0): - state = evm.message.block_env.state - precompile = evm.message.current_target - account = get_account(state, precompile) - set_account_balance( - state, precompile, account.balance - evm.message.value - ) diff --git a/src/ethereum/forks/monad_nine/vm/precompiled_contracts/staking.py b/src/ethereum/forks/monad_nine/vm/precompiled_contracts/staking.py index 21fa8262407..7baa0601293 100644 --- a/src/ethereum/forks/monad_nine/vm/precompiled_contracts/staking.py +++ b/src/ethereum/forks/monad_nine/vm/precompiled_contracts/staking.py @@ -15,7 +15,6 @@ from ethereum_types.numeric import U256, Uint -from ...state import get_account, set_account_balance from ...vm import Evm from ...vm.exceptions import InvalidParameter, RevertInMonadPrecompile from ...vm.gas import charge_gas @@ -397,13 +396,3 @@ def staking(evm: Evm) -> None: raise RevertInMonadPrecompile else: raise InvalidParameter - - # FIXME: is that so? - # Payable calls consume value (staking system absorbs it) - if is_payable and evm.message.value != U256(0): - state = evm.message.block_env.state - precompile = evm.message.current_target - account = get_account(state, precompile) - set_account_balance( - state, precompile, account.balance - evm.message.value - ) diff --git a/tests/monad_eight/staking_precompile/test_precompile_call.py b/tests/monad_eight/staking_precompile/test_precompile_call.py index 70844f8d255..a86ce34fbce 100644 --- a/tests/monad_eight/staking_precompile/test_precompile_call.py +++ b/tests/monad_eight/staking_precompile/test_precompile_call.py @@ -1035,14 +1035,11 @@ def test_tx_revert_scenarios( ) if scenario.should_succeed(func): - post: dict = {sender: Account(balance=0)} - # FIXME: which is correct? does the precompile hide the balance? # Value was transferred to precompile - # post = { - # sender: Account(balance=0), - # STAKING_PRECOMPILE: - # Account(balance=value) if value > 0 else None, - # } + post = { + sender: Account(balance=0), + STAKING_PRECOMPILE: Account(balance=value) if value > 0 else None, + } else: post = {sender: Account(balance=value)} From 0a0b148fe1dcf624b17756c1fe528b4ba87640e2 Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Thu, 12 Mar 2026 13:10:50 +0000 Subject: [PATCH 12/19] assert precompile balance in test_call_with_value Co-Authored-By: Claude claude-opus-4-6 --- .../test_precompile_call.py | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/tests/monad_eight/staking_precompile/test_precompile_call.py b/tests/monad_eight/staking_precompile/test_precompile_call.py index a86ce34fbce..d3eab67eafb 100644 --- a/tests/monad_eight/staking_precompile/test_precompile_call.py +++ b/tests/monad_eight/staking_precompile/test_precompile_call.py @@ -717,7 +717,7 @@ def test_revert_consumes_all_gas( "func", [pytest.param(f, id=f.name) for f in ALL_FUNCTIONS], ) -@pytest.mark.parametrize("value", [0, 1]) +@pytest.mark.parametrize("value", [0, 1, 2**128]) def test_call_with_value( blockchain_test: BlockchainTestFiller, pre: Alloc, @@ -785,18 +785,24 @@ def test_call_with_value( expected_return_size = len(err) expected_mload = _mload_of(err) + post: dict = { + contract_address: Account( + storage={ + slot_call_success: 1 if should_succeed else 0, + slot_return_size: expected_return_size, + slot_return_value: expected_mload, + slot_code_worked: value_code_worked, + }, + balance=0 if should_succeed else value, + ), + STAKING_PRECOMPILE: Account(balance=value) + if should_succeed and value > 0 + else None, + } + blockchain_test( pre=pre, - post={ - contract_address: Account( - storage={ - slot_call_success: 1 if should_succeed else 0, - slot_return_size: expected_return_size, - slot_return_value: expected_mload, - slot_code_worked: value_code_worked, - } - ), - }, + post=post, blocks=[Block(txs=[tx])], ) From e1ec07254542b8151672735632e31bf7d093fedb Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Fri, 13 Mar 2026 09:23:07 +0000 Subject: [PATCH 13/19] refactor resolve logic and gas defaults in test_precompile_call Extract ExpectedOutcome dataclass with factory methods, add _normalize() for parameterless function edge cases, and centralize gas computation in scenario_call_code so callers no longer need to manually calculate gas. Simplify resolve_outcome_pair by sorting scenarios by priority and adding stipend threshold check. Co-Authored-By: Claude claude-opus-4-6 --- .../test_precompile_call.py | 455 ++++++++++-------- 1 file changed, 244 insertions(+), 211 deletions(-) diff --git a/tests/monad_eight/staking_precompile/test_precompile_call.py b/tests/monad_eight/staking_precompile/test_precompile_call.py index d3eab67eafb..85bfefb0782 100644 --- a/tests/monad_eight/staking_precompile/test_precompile_call.py +++ b/tests/monad_eight/staking_precompile/test_precompile_call.py @@ -1,5 +1,5 @@ """ -Tests for staking precompile call behavior. +Tests for (stubbed and empty state-ed) staking precompile call behavior. Tests cover: - Input validation (selector, size) @@ -9,6 +9,9 @@ - Return data on success and revert """ +from __future__ import annotations + +from dataclasses import dataclass from enum import Enum, auto, unique from typing import Any @@ -72,6 +75,93 @@ def _mload_of(msg: bytes) -> int: return int.from_bytes((msg + b"\x00" * 32)[:32], "big") +@dataclass(frozen=True) +class ExpectedOutcome: + """Fully resolved expected result of a precompile call.""" + + call_success: int + return_size: int + return_word: int = 0 + + @staticmethod + def success(func: FunctionInfo) -> ExpectedOutcome: + """ + Build outcome for a successful precompile call, against + a stubbed implementation. + """ + return ExpectedOutcome( + call_success=1, + return_size=func.return_size, + return_word=func.first_return_word, + ) + + @staticmethod + def revert(error: str) -> ExpectedOutcome: + """Build outcome for a precompile revert with error data.""" + raw = error.encode() + return ExpectedOutcome( + call_success=0, + return_size=len(raw), + return_word=_mload_of(raw) if raw else 0, + ) + + @staticmethod + def by_function(func: FunctionInfo) -> ExpectedOutcome: + """ + Outcome when all checks pass — may still revert on empty stubbed + state. + """ + if func.empty_state_error: + return ExpectedOutcome.revert(func.empty_state_error) + return ExpectedOutcome.success(func) + + @staticmethod + def by_input_size_delta( + func: FunctionInfo, input_size_delta: int + ) -> ExpectedOutcome: + """Resolve outcome based on calldata size delta.""" + input_size = func.calldata_size + input_size_delta + if input_size < 4: + return ExpectedOutcome.revert(ERROR_METHOD_NOT_SUPPORTED) + if func.overrides_size_errors: + return ExpectedOutcome.revert(func.empty_state_error) + parameterless = func.calldata_size == 4 + is_short = input_size < func.calldata_size + is_long = input_size > func.calldata_size + size_ok = not is_short and (not is_long or parameterless) + if size_ok: + return ExpectedOutcome.by_function(func) + if is_short: + return ExpectedOutcome.revert(ERROR_INPUT_TOO_SHORT) + return ExpectedOutcome.revert(ERROR_INVALID_INPUT) + + @staticmethod + def by_selector(selector: int) -> ExpectedOutcome: + """Resolve outcome based on function selector.""" + func = FUNC_BY_SELECTOR.get(selector) + if func is None: + return ExpectedOutcome.revert(ERROR_METHOD_NOT_SUPPORTED) + return ExpectedOutcome.by_function(func) + + @staticmethod + def by_call_opcode(func: FunctionInfo, call_opcode: Op) -> ExpectedOutcome: + """Resolve outcome based on call opcode type.""" + if call_opcode != Op.CALL: + return ExpectedOutcome.revert("") + return ExpectedOutcome.by_function(func) + + @staticmethod + def by_value(func: FunctionInfo, value: int) -> ExpectedOutcome: + """Resolve outcome based on value transfer amount.""" + if value == 0: + return ExpectedOutcome.by_function(func) + if not func.is_payable: + return ExpectedOutcome.revert(ERROR_VALUE_NONZERO) + if func.nonzero_value_error: + return ExpectedOutcome.revert(func.nonzero_value_error) + return ExpectedOutcome.by_function(func) + + @unique class CallScenario(Enum): """Precompile call scenarios for parametrized tests.""" @@ -86,56 +176,9 @@ class CallScenario(Enum): NONZERO_VALUE = auto() LOW_GAS = auto() - def should_succeed(self, func: FunctionInfo) -> bool: - """Return whether this scenario succeeds for the given function.""" - if func.empty_state_error: - return False - if self == CallScenario.SUCCESS: - return True - if self == CallScenario.NONZERO_VALUE: - return func.is_payable and not func.nonzero_value_error - if self == CallScenario.EXTRA_CALLDATA: - return func.calldata_size == 4 - return False - - def error_message(self, func: "FunctionInfo | None" = None) -> bytes: - """Return raw ASCII error bytes for this scenario.""" - match self: - case CallScenario.SUCCESS: - if func and func.empty_state_error: - return func.empty_state_error.encode() - return b"" - case ( - CallScenario.NOT_CALL - | CallScenario.DELEGATE_TO_PRECOMPILE - | CallScenario.LOW_GAS - ): - return b"" - case CallScenario.WRONG_SELECTOR | CallScenario.TRUNCATED_SELECTOR: - return ERROR_METHOD_NOT_SUPPORTED.encode() - case CallScenario.SHORT_CALLDATA: - if func and func.overrides_size_errors: - return func.empty_state_error.encode() - return ERROR_INPUT_TOO_SHORT.encode() - case CallScenario.EXTRA_CALLDATA: - if func and func.overrides_size_errors: - return func.empty_state_error.encode() - if func and func.calldata_size == 4: - return b"" - return ERROR_INVALID_INPUT.encode() - case CallScenario.NONZERO_VALUE: - if func and func.nonzero_value_error: - return func.nonzero_value_error.encode() - if func and func.is_payable and func.empty_state_error: - return func.empty_state_error.encode() - return ERROR_VALUE_NONZERO.encode() - return b"" - @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, @@ -145,14 +188,107 @@ def check_priority(self) -> int: CallScenario.NONZERO_VALUE, CallScenario.SHORT_CALLDATA, CallScenario.EXTRA_CALLDATA, + CallScenario.SUCCESS, ] return order.index(self) -def call_code( +def _normalize(scenario: CallScenario, func: FunctionInfo) -> CallScenario: + """Map scenarios to their effective form for a given function.""" + if func.calldata_size == 4: + if scenario == CallScenario.SHORT_CALLDATA: + return CallScenario.TRUNCATED_SELECTOR + if scenario == CallScenario.EXTRA_CALLDATA: + return CallScenario.SUCCESS + return scenario + + +def resolve_outcome( + func: FunctionInfo, scenario: CallScenario +) -> ExpectedOutcome: + """ + Resolve expected outcome for a single scenario. + """ + scenario = _normalize(scenario, func) + match scenario: + case CallScenario.SUCCESS: + return ExpectedOutcome.by_function(func) + case ( + CallScenario.NOT_CALL + | CallScenario.DELEGATE_TO_PRECOMPILE + | CallScenario.LOW_GAS + ): + return ExpectedOutcome.revert("") + case CallScenario.TRUNCATED_SELECTOR | CallScenario.WRONG_SELECTOR: + return ExpectedOutcome.revert(ERROR_METHOD_NOT_SUPPORTED) + case CallScenario.SHORT_CALLDATA: + if func.overrides_size_errors: + return ExpectedOutcome.revert(func.empty_state_error) + return ExpectedOutcome.revert(ERROR_INPUT_TOO_SHORT) + case CallScenario.EXTRA_CALLDATA: + if func.overrides_size_errors: + return ExpectedOutcome.revert(func.empty_state_error) + return ExpectedOutcome.revert(ERROR_INVALID_INPUT) + case CallScenario.NONZERO_VALUE: + if not func.is_payable: + return ExpectedOutcome.revert(ERROR_VALUE_NONZERO) + if func.nonzero_value_error: + return ExpectedOutcome.revert(func.nonzero_value_error) + return ExpectedOutcome.by_function(func) + raise ValueError(f"Unknown scenario: {scenario}") + + +def resolve_outcome_pair( + func: FunctionInfo, + scenario1: CallScenario, + scenario2: CallScenario, +) -> ExpectedOutcome: + """ + Resolve expected outcome when two failure scenarios combine. + + The higher-priority scenario prevails, with special handling + for LOW_GAS interactions and NONZERO_VALUE pass-through on + payable functions. + """ + scenario1 = _normalize(scenario1, func) + scenario2 = _normalize(scenario2, func) + + # Sort so prevailing (higher priority) is first + if scenario1.check_priority > scenario2.check_priority: + scenario1, scenario2 = scenario2, scenario1 + + # EVM adds 2300 stipend for value>0; if the stipend covers the + # function's gas cost, the call succeeds despite LOW_GAS. + if ( + scenario1 == CallScenario.LOW_GAS + and scenario2 == CallScenario.NONZERO_VALUE + and func.gas_cost <= 2300 + ): + return resolve_outcome(func, scenario2) + + # The additional gas check for fallback function special gas cost. + if ( + scenario1 + in ( + CallScenario.TRUNCATED_SELECTOR, + CallScenario.WRONG_SELECTOR, + ) + and scenario2 == CallScenario.LOW_GAS + ): + return ExpectedOutcome.revert("") + + # Payable functions pass the NONZERO_VALUE check; + # the other (lower-priority) scenario fires instead + if scenario1 == CallScenario.NONZERO_VALUE and func.is_payable: + return resolve_outcome(func, scenario2) + + return resolve_outcome(func, scenario1) + + +def scenario_call_code( *scenarios: CallScenario, func: FunctionInfo, - gas: int | Bytecode = 0, + gas: int | Bytecode | None = None, ret_offset: int = 0, ret_size: int = 0, delegating_eoa: Address | None = None, @@ -199,14 +335,11 @@ def call_code( "ret buffer must come after args buffer" ) - if CallScenario.LOW_GAS in scenario_set: - if ( - CallScenario.WRONG_SELECTOR in scenario_set - or CallScenario.TRUNCATED_SELECTOR in scenario_set - ): - gas = GAS_UNKNOWN_SELECTOR - 1 + if gas is None: + if CallScenario.LOW_GAS in scenario_set: + gas = min(func.gas_cost, GAS_UNKNOWN_SELECTOR) - 1 else: - gas = func.gas_cost - 1 + gas = max(func.gas_cost, GAS_UNKNOWN_SELECTOR) if CallScenario.NONZERO_VALUE in scenario_set: value = 1 @@ -306,32 +439,15 @@ def test_input_size( sender=pre.fund_eoa(), ) - is_short = input_size < func.calldata_size - is_long = input_size > func.calldata_size - parameterless = func.calldata_size == 4 - size_ok = input_size == func.calldata_size or (is_long and parameterless) - should_succeed = size_ok and not func.empty_state_error - - if func.overrides_size_errors and input_size >= 4: - expected_return_size = len(func.empty_state_error) - elif size_ok and func.empty_state_error: - expected_return_size = len(func.empty_state_error) - elif should_succeed: - expected_return_size = func.return_size - elif input_size < 4: - expected_return_size = len(ERROR_METHOD_NOT_SUPPORTED) - elif is_short: - expected_return_size = len(ERROR_INPUT_TOO_SHORT) - else: - expected_return_size = len(ERROR_INVALID_INPUT) + outcome = ExpectedOutcome.by_input_size_delta(func, input_size_delta) 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_call_success: outcome.call_success, + slot_return_size: outcome.return_size, slot_code_worked: value_code_worked, } ), @@ -367,16 +483,10 @@ def test_selector( calldata_setup = build_calldata(func.selector, func.calldata_size) args_size = func.calldata_size gas = func.gas_cost + 10000 - should_succeed = not func.empty_state_error - expected_return_size = ( - func.return_size if should_succeed else len(func.empty_state_error) - ) else: calldata_setup = Op.MSTORE(32, selector) args_size = 4 gas = GAS_UNKNOWN_SELECTOR + 10000 - should_succeed = False - expected_return_size = len(ERROR_METHOD_NOT_SUPPORTED) contract = ( calldata_setup @@ -402,13 +512,15 @@ def test_selector( sender=pre.fund_eoa(), ) + outcome = ExpectedOutcome.by_selector(selector) + 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_call_success: outcome.call_success, + slot_return_size: outcome.return_size, slot_code_worked: value_code_worked, } ), @@ -457,14 +569,18 @@ def test_gas( gas_limit=generous_gas(fork), ) - should_succeed = enough_gas and not func.empty_state_error + outcome = ( + ExpectedOutcome.by_function(func) + if enough_gas + else ExpectedOutcome.revert("") + ) state_test( pre=pre, post={ contract_address: Account( storage={ - slot_call_success: 1 if should_succeed else 0, + slot_call_success: outcome.call_success, slot_code_worked: value_code_worked, } ) @@ -514,23 +630,15 @@ def test_call_opcodes( sender=pre.fund_eoa(), ) - is_call = call_opcode == Op.CALL - should_succeed = is_call and not func.empty_state_error - - if should_succeed: - expected_return_size = func.return_size - elif is_call and func.empty_state_error: - expected_return_size = len(func.empty_state_error) - else: - expected_return_size = 0 + outcome = ExpectedOutcome.by_call_opcode(func, call_opcode) 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_call_success: outcome.call_success, + slot_return_size: outcome.return_size, slot_code_worked: value_code_worked, } ), @@ -545,7 +653,7 @@ def test_call_opcodes( ) @pytest.mark.parametrize( "scenario", - [s for s in CallScenario if s != CallScenario.LOW_GAS], + list(CallScenario), ) def test_revert_returns( blockchain_test: BlockchainTestFiller, @@ -557,11 +665,6 @@ def test_revert_returns( """ Test return data on success and on each revert reason. """ - # Always provide enough gas regardless of scenario: - # TRUNCATED_SELECTOR/WRONG_SELECTOR charge GAS_UNKNOWN_SELECTOR - # before the function's own gas cost is known. - gas = max(func.gas_cost, GAS_UNKNOWN_SELECTOR) + 10000 - mem_end = _calldata_mem_end(func.calldata_size) ret_offset = max(mem_end, 96) rdc_offset = ret_offset + 32 @@ -581,10 +684,9 @@ def test_revert_returns( contract = ( Op.SSTORE( slot_call_success, - call_code( + scenario_call_code( scenario, func=func, - gas=gas, ret_offset=ret_offset, ret_size=32, delegating_eoa=delegating_eoa, @@ -605,28 +707,17 @@ def test_revert_returns( authorization_list=authorization_list, ) - ok = scenario.should_succeed(func) - - if scenario == CallScenario.SHORT_CALLDATA and func.calldata_size == 4: - # SHORT_CALLDATA on a 4-byte function sends 3 bytes, hitting the - # truncated-selector path rather than the size-mismatch path. - err = ERROR_METHOD_NOT_SUPPORTED.encode() - else: - err = scenario.error_message(func) - expected_return_size = func.return_size if ok else (len(err) if err else 0) - expected_mload = ( - func.first_return_word if ok else _mload_of(err) if err else 0 - ) + outcome = resolve_outcome(func, scenario) blockchain_test( pre=pre, post={ contract_address: Account( storage={ - slot_call_success: 1 if ok else 0, - slot_return_size: expected_return_size, - slot_ret_buffer_value: expected_mload, - slot_return_value: expected_mload, + slot_call_success: outcome.call_success, + slot_return_size: outcome.return_size, + slot_ret_buffer_value: outcome.return_word, + slot_return_value: outcome.return_word, slot_code_worked: value_code_worked, } ), @@ -675,7 +766,7 @@ def test_revert_consumes_all_gas( + Op.SSTORE(slot_all_gas_consumed, 1) + Op.SSTORE( slot_call_success, - call_code( + scenario_call_code( scenario, func=func, gas=Op.GAS, @@ -696,7 +787,7 @@ def test_revert_consumes_all_gas( authorization_list=authorization_list, ) - ok = scenario.should_succeed(func) + outcome = resolve_outcome(func, scenario) blockchain_test( pre=pre, @@ -704,8 +795,8 @@ def test_revert_consumes_all_gas( contract_address: Account( storage={ slot_code_worked: value_code_worked, - slot_call_success: 1 if ok else 0, - slot_all_gas_consumed: 0 if ok else 1, + slot_call_success: outcome.call_success, + slot_all_gas_consumed: 1 - outcome.call_success, } ), }, @@ -762,41 +853,20 @@ def test_call_with_value( sender=pre.fund_eoa(), ) - value_ok = value == 0 or func.is_payable - should_succeed = ( - value_ok - and not func.empty_state_error - and not (value > 0 and func.nonzero_value_error) - ) - - if should_succeed: - expected_return_size = func.return_size - expected_mload = func.first_return_word - elif not value_ok: - err = ERROR_VALUE_NONZERO.encode() - expected_return_size = len(err) - expected_mload = _mload_of(err) - elif value > 0 and func.nonzero_value_error: - err = func.nonzero_value_error.encode() - expected_return_size = len(err) - expected_mload = _mload_of(err) - else: - err = func.empty_state_error.encode() - expected_return_size = len(err) - expected_mload = _mload_of(err) + outcome = ExpectedOutcome.by_value(func, value) post: dict = { contract_address: Account( storage={ - slot_call_success: 1 if should_succeed else 0, - slot_return_size: expected_return_size, - slot_return_value: expected_mload, + slot_call_success: outcome.call_success, + slot_return_size: outcome.return_size, + slot_return_value: outcome.return_word, slot_code_worked: value_code_worked, }, - balance=0 if should_succeed else value, + balance=0 if outcome.call_success else value, ), STAKING_PRECOMPILE: Account(balance=value) - if should_succeed and value > 0 + if outcome.call_success and value > 0 else None, } @@ -846,47 +916,6 @@ def test_check_order( Each combination triggers exactly two failure causes. The test derives the expected outcome from the higher-priority failure. """ - scenarios_set = frozenset({scenario1, scenario2}) - call_succeeds = False - - if scenarios_set == frozenset( - {CallScenario.LOW_GAS, CallScenario.NONZERO_VALUE} - ): - if func.is_payable and not func.nonzero_value_error: - # EVM adds 2300 stipend for value>0, overcoming LOW_GAS; - # payable func accepts value -> call succeeds - expected_msg = b"" - call_succeeds = True - elif func.is_payable: - expected_msg = func.nonzero_value_error.encode() - else: - expected_msg = CallScenario.NONZERO_VALUE.error_message(func) - elif CallScenario.LOW_GAS in scenarios_set and ( - CallScenario.TRUNCATED_SELECTOR in scenarios_set - or CallScenario.WRONG_SELECTOR in scenarios_set - ): - # gas = GAS_UNKNOWN_SELECTOR-1 < GAS_UNKNOWN_SELECTOR -> OOG - expected_msg = b"" - else: - prevailing = min(scenario1, scenario2, key=lambda s: s.check_priority) - expected_msg = prevailing.error_message(func) or b"" - if prevailing == CallScenario.NONZERO_VALUE: - if ( - CallScenario.SHORT_CALLDATA in scenarios_set - and func.calldata_size == 4 - ): - # SHORT on a 4-byte func sends 3 bytes -> truncated - # selector path -> "method not supported" - expected_msg = ERROR_METHOD_NOT_SUPPORTED.encode() - elif func.is_payable: - # Payable accepts value check; the other scenario fires - other = ( - scenario1 - if scenario1 != CallScenario.NONZERO_VALUE - else scenario2 - ) - expected_msg = other.error_message(func) or b"" - mem_end = _calldata_mem_end(func.calldata_size) ret_offset = max(mem_end, 96) rdc_offset = ret_offset + 32 @@ -906,11 +935,10 @@ def test_check_order( contract = ( Op.SSTORE( slot_call_success, - call_code( + scenario_call_code( scenario1, scenario2, func=func, - gas=max(func.gas_cost, GAS_UNKNOWN_SELECTOR) + 10000, ret_offset=ret_offset, ret_size=32, delegating_eoa=delegating_eoa, @@ -930,21 +958,16 @@ def test_check_order( authorization_list=authorization_list, ) - if call_succeeds: - expected_return_size = func.return_size - expected_mload = func.first_return_word - else: - expected_return_size = len(expected_msg) - expected_mload = _mload_of(expected_msg) if expected_msg else 0 + outcome = resolve_outcome_pair(func, scenario1, scenario2) blockchain_test( pre=pre, post={ contract_address: Account( storage={ - slot_call_success: 1 if call_succeeds else 0, - slot_return_size: expected_return_size, - slot_return_value: expected_mload, + slot_call_success: outcome.call_success, + slot_return_size: outcome.return_size, + slot_return_value: outcome.return_word, slot_code_worked: value_code_worked, } ), @@ -1027,6 +1050,8 @@ def test_tx_revert_scenarios( gas_cost = gas_limit * gas_price sender = pre.fund_eoa(gas_cost + value) + outcome = resolve_outcome(func, scenario) + tx = Transaction( gas_limit=gas_limit, max_fee_per_gas=gas_price, @@ -1036,13 +1061,12 @@ def test_tx_revert_scenarios( data=calldata, value=value, expected_receipt=TransactionReceipt( - status=1 if scenario.should_succeed(func) else 0, + status=outcome.call_success, ), ) - if scenario.should_succeed(func): - # Value was transferred to precompile - post = { + if outcome.call_success: + post: dict = { sender: Account(balance=0), STAKING_PRECOMPILE: Account(balance=value) if value > 0 else None, } @@ -1057,6 +1081,13 @@ def test_tx_revert_scenarios( _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}), } @@ -1096,6 +1127,9 @@ def test_tx_revert_scenario_pairs( gas_cost = gas_limit * gas_price sender = pre.fund_eoa(gas_cost + value) + outcome1 = resolve_outcome(func, scenario1) + outcome2 = resolve_outcome(func, scenario2) + tx = Transaction( gas_limit=gas_limit, max_fee_per_gas=gas_price, @@ -1106,8 +1140,7 @@ def test_tx_revert_scenario_pairs( value=value, expected_receipt=TransactionReceipt( status=0x1 - if scenario1.should_succeed(func) - and scenario2.should_succeed(func) + if outcome1.call_success and outcome2.call_success else 0x0 ), ) From 8b389a5d141d5a6eed33f991c11dc59f40020b10 Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Fri, 13 Mar 2026 13:24:13 +0000 Subject: [PATCH 14/19] Add test_check_order_triple Co-Authored-By: Claude claude-opus-4-6 --- .../test_precompile_call.py | 183 +++++++++++++++++- 1 file changed, 176 insertions(+), 7 deletions(-) diff --git a/tests/monad_eight/staking_precompile/test_precompile_call.py b/tests/monad_eight/staking_precompile/test_precompile_call.py index 85bfefb0782..f17c1aa35a2 100644 --- a/tests/monad_eight/staking_precompile/test_precompile_call.py +++ b/tests/monad_eight/staking_precompile/test_precompile_call.py @@ -50,6 +50,8 @@ REFERENCE_SPEC_GIT_PATH = ref_spec_staking.git_path REFERENCE_SPEC_VERSION = ref_spec_staking.version +GAS_STIPEND = 2300 + slot_code_worked = 0x1 value_code_worked = 0x1234 slot_call_success = 0x2 @@ -238,6 +240,32 @@ def resolve_outcome( raise ValueError(f"Unknown scenario: {scenario}") +def _stipend_neutralizes_low_gas( + func: FunctionInfo, + scenario_set: set[CallScenario], +) -> bool: + """ + Check if the EVM value-transfer stipend overcomes how little + gas is provided in LOW_GAS scenarios. + """ + if not ( + {CallScenario.LOW_GAS, CallScenario.NONZERO_VALUE} <= scenario_set + ): + return False + + # Given how LOW_GAS gas is calculated, unknown selector + # scenarios can never have stipend compensate for gas + # shortage. + assert GAS_UNKNOWN_SELECTOR > GAS_STIPEND + known_selector = scenario_set.isdisjoint( + { + CallScenario.TRUNCATED_SELECTOR, + CallScenario.WRONG_SELECTOR, + } + ) + return known_selector and func.gas_cost <= GAS_STIPEND + + def resolve_outcome_pair( func: FunctionInfo, scenario1: CallScenario, @@ -257,12 +285,10 @@ def resolve_outcome_pair( if scenario1.check_priority > scenario2.check_priority: scenario1, scenario2 = scenario2, scenario1 - # EVM adds 2300 stipend for value>0; if the stipend covers the - # function's gas cost, the call succeeds despite LOW_GAS. - if ( - scenario1 == CallScenario.LOW_GAS - and scenario2 == CallScenario.NONZERO_VALUE - and func.gas_cost <= 2300 + if _stipend_neutralizes_low_gas( + # True iff `scenario1 == LOW_GAS and scenario2 == NONZERO_VALUE` + func, + {scenario1, scenario2}, ): return resolve_outcome(func, scenario2) @@ -285,6 +311,28 @@ def resolve_outcome_pair( return resolve_outcome(func, scenario1) +def resolve_outcome_triple( + func: FunctionInfo, + s1: CallScenario, + s2: CallScenario, + s3: CallScenario, +) -> ExpectedOutcome: + """Resolve expected outcome for three combined scenarios.""" + scenarios = sorted( + [_normalize(s, func) for s in (s1, s2, s3)], + key=lambda s: s.check_priority, + ) + if _stipend_neutralizes_low_gas(func, set(scenarios)): + scenarios.remove(CallScenario.LOW_GAS) + + if scenarios[0] == CallScenario.NONZERO_VALUE and func.is_payable: + scenarios = scenarios[1:] + + if len(scenarios) == 1: + return resolve_outcome(func, scenarios[0]) + return resolve_outcome_pair(func, scenarios[0], scenarios[1]) + + def scenario_call_code( *scenarios: CallScenario, func: FunctionInfo, @@ -337,7 +385,12 @@ def scenario_call_code( if gas is None: if CallScenario.LOW_GAS in scenario_set: - gas = min(func.gas_cost, GAS_UNKNOWN_SELECTOR) - 1 + gas = max( + 0, + # Subtracting also the stipend in case the + # value sent would cause stipend to be added. + min(func.gas_cost, GAS_UNKNOWN_SELECTOR) - GAS_STIPEND - 1, + ) else: gas = max(func.gas_cost, GAS_UNKNOWN_SELECTOR) @@ -976,6 +1029,122 @@ def test_check_order( ) +def _pairwise_compatible( + scenarios: tuple[CallScenario, ...], +) -> bool: + """Check that no pair in the tuple is incompatible.""" + return all( + frozenset({a, b}) not in _INCOMPATIBLE_SCENARIOS + for i, a in enumerate(scenarios) + for b in scenarios[i + 1 :] + ) + + +_CHECK_ORDER_TRIPLES = [ + pytest.param( + s1, + s2, + s3, + id=f"{s1.name.lower()}__{s2.name.lower()}__{s3.name.lower()}", + ) + for s1 in CallScenario + for s2 in CallScenario + for s3 in CallScenario + if CallScenario.SUCCESS not in {s1, s2, s3} + and s1.check_priority < s2.check_priority < s3.check_priority + and _pairwise_compatible((s1, s2, s3)) +] + + +@pytest.mark.parametrize( + "func", + [pytest.param(f, id=f.name) for f in REPRESENTATIVE_FUNCTIONS], +) +@pytest.mark.parametrize("scenario1,scenario2,scenario3", _CHECK_ORDER_TRIPLES) +def test_check_order_triple( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, + func: FunctionInfo, + scenario1: CallScenario, + scenario2: CallScenario, + scenario3: CallScenario, +) -> None: + """ + Test precompile check priority with three combined failures. + + Each combination triggers exactly three failure causes. The test + derives the expected outcome from the priority interactions. + """ + normalized = { + _normalize(s, func) for s in (scenario1, scenario2, scenario3) + } + if not _pairwise_compatible(tuple(normalized)): + pytest.skip("normalized scenarios are incompatible") + + mem_end = _calldata_mem_end(func.calldata_size) + ret_offset = max(mem_end, 96) + rdc_offset = ret_offset + 32 + + scenarios = {scenario1, scenario2, scenario3} + + delegating_eoa: Address | None = None + authorization_list = None + if CallScenario.DELEGATE_TO_PRECOMPILE in scenarios: + delegating_eoa = pre.fund_eoa() + authorization_list = [ + AuthorizationTuple( + address=STAKING_PRECOMPILE, + nonce=0, + signer=delegating_eoa, + ) + ] + + contract = ( + Op.SSTORE( + slot_call_success, + scenario_call_code( + scenario1, + scenario2, + scenario3, + func=func, + 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, + ) + + outcome = resolve_outcome_triple(func, scenario1, scenario2, scenario3) + + blockchain_test( + pre=pre, + post={ + contract_address: Account( + storage={ + slot_call_success: outcome.call_success, + slot_return_size: outcome.return_size, + slot_return_value: outcome.return_word, + slot_code_worked: value_code_worked, + } + ), + }, + blocks=[Block(txs=[tx])], + ) + + # --- Direct-transaction tests --- From 357d828f64fe492ed7ed4d5ce94b1fe6b961a840 Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Fri, 13 Mar 2026 13:30:40 +0000 Subject: [PATCH 15/19] Add test_tx_revert_scenario_triples Co-Authored-By: Claude claude-opus-4-6 --- .../test_precompile_call.py | 89 ++++++++++++++++++- 1 file changed, 88 insertions(+), 1 deletion(-) diff --git a/tests/monad_eight/staking_precompile/test_precompile_call.py b/tests/monad_eight/staking_precompile/test_precompile_call.py index f17c1aa35a2..36f35ca342f 100644 --- a/tests/monad_eight/staking_precompile/test_precompile_call.py +++ b/tests/monad_eight/staking_precompile/test_precompile_call.py @@ -1031,10 +1031,11 @@ def test_check_order( def _pairwise_compatible( scenarios: tuple[CallScenario, ...], + incompatible: set[frozenset[CallScenario]] = _INCOMPATIBLE_SCENARIOS, ) -> bool: """Check that no pair in the tuple is incompatible.""" return all( - frozenset({a, b}) not in _INCOMPATIBLE_SCENARIOS + frozenset({a, b}) not in incompatible for i, a in enumerate(scenarios) for b in scenarios[i + 1 :] ) @@ -1325,6 +1326,92 @@ def test_tx_revert_scenario_pairs( ) +_TX_SCENARIO_TRIPLES = [ + pytest.param( + s1, + s2, + s3, + id=f"{s1.name.lower()}__{s2.name.lower()}__{s3.name.lower()}", + ) + for s1 in CallScenario + for s2 in CallScenario + for s3 in CallScenario + if CallScenario.SUCCESS not in {s1, s2, s3} + and CallScenario.NOT_CALL not in {s1, s2, s3} + and s1.check_priority < s2.check_priority < s3.check_priority + and _pairwise_compatible((s1, s2, s3), _TX_INCOMPATIBLE_SCENARIOS) +] + + +@pytest.mark.parametrize( + "func", + [pytest.param(f, id=f.name) for f in REPRESENTATIVE_FUNCTIONS], +) +@pytest.mark.parametrize("scenario1,scenario2,scenario3", _TX_SCENARIO_TRIPLES) +def test_tx_revert_scenario_triples( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + func: FunctionInfo, + scenario1: CallScenario, + scenario2: CallScenario, + scenario3: CallScenario, +) -> None: + """ + Test when the precompile is called directly as transaction + `to` with 3 reasons to revert. + """ + normalized = { + _normalize(s, func) for s in (scenario1, scenario2, scenario3) + } + if not _pairwise_compatible(tuple(normalized), _TX_INCOMPATIBLE_SCENARIOS): + pytest.skip("normalized scenarios are incompatible") + + gas_price = 10 + + calldata, value, to, gas_limit = _tx_params( + scenario1, + scenario2, + scenario3, + func=func, + pre=pre, + fork=fork, + ) + gas_cost = gas_limit * gas_price + sender = pre.fund_eoa(gas_cost + value) + + outcome1 = resolve_outcome(func, scenario1) + outcome2 = resolve_outcome(func, scenario2) + outcome3 = resolve_outcome(func, scenario3) + + 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 outcome1.call_success + and outcome2.call_success + and outcome3.call_success + else 0x0 + ), + ) + + post: dict = { + sender: Account(balance=value), + } + + state_test( + pre=pre, + post=post, + tx=tx, + ) + + @pytest.mark.parametrize( "selector", [ From 08f8d427dbcc81f58d612c01a9df8ce34b2f1827 Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:14:08 +0000 Subject: [PATCH 16/19] refactor: centralize memory layout constants in helpers.py Co-Authored-By: Claude claude-opus-4-6 --- .../monad_eight/staking_precompile/helpers.py | 39 ++++++++++-- .../test_precompile_call.py | 59 ++++++++----------- 2 files changed, 59 insertions(+), 39 deletions(-) diff --git a/tests/monad_eight/staking_precompile/helpers.py b/tests/monad_eight/staking_precompile/helpers.py index 2f16f68c5fb..ef43f9b968f 100644 --- a/tests/monad_eight/staking_precompile/helpers.py +++ b/tests/monad_eight/staking_precompile/helpers.py @@ -3,6 +3,16 @@ from execution_testing import Bytecode, Op from execution_testing.forks.helpers import Fork +# Memory layout for precompile call tests. +# MSTORE writes a 32-byte word; the 4-byte selector sits at the +# rightmost bytes of that word. +WRONG_SEL_MSTORE_OFFSET = 0 +CORRECT_SEL_MSTORE_OFFSET = 32 + +# Where the 4-byte selectors actually start in memory: +WRONG_SEL_ARGS_OFFSET = WRONG_SEL_MSTORE_OFFSET + 28 +CORRECT_SEL_ARGS_OFFSET = CORRECT_SEL_MSTORE_OFFSET + 28 + def generous_gas(fork: Fork) -> int: """Return generous parametrized gas to always be enough.""" @@ -27,13 +37,32 @@ def tx_calldata(selector: int, calldata_size: int) -> bytes: return sel_bytes + b"\x00" * max(0, calldata_size - 4) +def calldata_mem_end(calldata_size: int) -> int: + """ + Return safe first memory offset past all written regions. + + Accounts for EXTRA_CALLDATA (+1 byte beyond calldata_size) + and both MSTORE regions. + """ + return ( + max( + CORRECT_SEL_ARGS_OFFSET + calldata_size, + CORRECT_SEL_MSTORE_OFFSET + 32, + ) + + 1 + ) + + def build_calldata(selector: int, calldata_size: int) -> Bytecode: """ - Build bytecode that stores a selector and padding in memory. + Build bytecode that stores a selector in memory. - Place the 4-byte selector at mem[60:64] and assume mem[64:..] has - rest of calldata + Place the 4-byte selector at + mem[CORRECT_SEL_ARGS_OFFSET:CORRECT_SEL_ARGS_OFFSET+4] + and leave the rest zero-initialized for calldata args. """ - selector_calldata_offset = 32 - code = Op.MSTORE(selector_calldata_offset, selector) + # Not used b/c input is zero beyond the selector; + # left here only for clarity + del calldata_size + code = Op.MSTORE(CORRECT_SEL_MSTORE_OFFSET, selector) return code diff --git a/tests/monad_eight/staking_precompile/test_precompile_call.py b/tests/monad_eight/staking_precompile/test_precompile_call.py index 36f35ca342f..bd14618f74a 100644 --- a/tests/monad_eight/staking_precompile/test_precompile_call.py +++ b/tests/monad_eight/staking_precompile/test_precompile_call.py @@ -32,7 +32,15 @@ from execution_testing.forks.helpers import Fork from execution_testing.test_types.receipt_types import TransactionReceipt -from .helpers import build_calldata, generous_gas, tx_calldata +from .helpers import ( + CORRECT_SEL_ARGS_OFFSET, + WRONG_SEL_ARGS_OFFSET, + WRONG_SEL_MSTORE_OFFSET, + build_calldata, + calldata_mem_end, + generous_gas, + tx_calldata, +) from .spec import ( ALL_FUNCTIONS, ERROR_INPUT_TOO_SHORT, @@ -61,11 +69,6 @@ slot_all_gas_consumed = 0x6 -def _calldata_mem_end(calldata_size: int) -> int: - """Return the first memory offset after build_calldata's writes.""" - return 60 + calldata_size + 1 - - def _mload_of(msg: bytes) -> int: """ Compute the MLOAD uint256 value after a raw error message is @@ -350,32 +353,24 @@ def scenario_call_code( """ scenario_set = set(scenarios) - # Memory layout: non-overlapping buffers - # MSTORE(0, 0xDEADBEEF) -> wrong selector at mem[28:32] - # build_calldata(selector, size) -> selector at mem[60:64] - wrong_sel_args_offset = 28 - correct_sel_args_offset = 60 - - # NOTE: in case of wrong selector scenario, we do not include any - # extra calldata for (non-existent) function arguments setup: Bytecode = build_calldata( func.selector, func.calldata_size - ) + Op.MSTORE(0, 0xDEADBEEF) + ) + Op.MSTORE(WRONG_SEL_MSTORE_OFFSET, 0xDEADBEEF) if CallScenario.WRONG_SELECTOR in scenario_set: - args_offset = wrong_sel_args_offset + args_offset = WRONG_SEL_ARGS_OFFSET args_size = 4 elif CallScenario.TRUNCATED_SELECTOR in scenario_set: - args_offset = correct_sel_args_offset + args_offset = CORRECT_SEL_ARGS_OFFSET args_size = 3 elif CallScenario.SHORT_CALLDATA in scenario_set: - args_offset = correct_sel_args_offset + args_offset = CORRECT_SEL_ARGS_OFFSET args_size = func.calldata_size - 1 elif CallScenario.EXTRA_CALLDATA in scenario_set: - args_offset = correct_sel_args_offset + args_offset = CORRECT_SEL_ARGS_OFFSET args_size = func.calldata_size + 1 else: - args_offset = correct_sel_args_offset + args_offset = CORRECT_SEL_ARGS_OFFSET args_size = func.calldata_size if ret_size > 0: @@ -475,7 +470,7 @@ def test_input_size( Op.CALL( gas=gas, address=STAKING_PRECOMPILE, - args_offset=60, + args_offset=CORRECT_SEL_ARGS_OFFSET, args_size=input_size, ret_offset=0, ret_size=32, @@ -548,7 +543,7 @@ def test_selector( Op.CALL( gas=gas, address=STAKING_PRECOMPILE, - args_offset=60, + args_offset=CORRECT_SEL_ARGS_OFFSET, args_size=args_size, ret_offset=0, ret_size=32, @@ -606,7 +601,7 @@ def test_gas( Op.CALL( gas=gas, address=STAKING_PRECOMPILE, - args_offset=60, + args_offset=CORRECT_SEL_ARGS_OFFSET, args_size=func.calldata_size, ret_offset=0, ret_size=32, @@ -666,7 +661,7 @@ def test_call_opcodes( call_opcode( gas=func.gas_cost + 10000, address=STAKING_PRECOMPILE, - args_offset=60, + args_offset=CORRECT_SEL_ARGS_OFFSET, args_size=func.calldata_size, ret_offset=0, ret_size=32, @@ -718,8 +713,7 @@ def test_revert_returns( """ Test return data on success and on each revert reason. """ - mem_end = _calldata_mem_end(func.calldata_size) - ret_offset = max(mem_end, 96) + ret_offset = calldata_mem_end(func.calldata_size) rdc_offset = ret_offset + 32 delegating_eoa: Address | None = None @@ -875,8 +869,7 @@ def test_call_with_value( Payable functions accept value; non-payable functions revert with "value is nonzero". """ - mem_end = _calldata_mem_end(func.calldata_size) - ret_offset = max(mem_end, 96) + ret_offset = calldata_mem_end(func.calldata_size) rdc_offset = ret_offset + 32 contract = ( @@ -887,7 +880,7 @@ def test_call_with_value( gas=func.gas_cost + 10000, address=STAKING_PRECOMPILE, value=value, - args_offset=60, + args_offset=CORRECT_SEL_ARGS_OFFSET, args_size=func.calldata_size, ret_offset=ret_offset, ret_size=32, @@ -969,8 +962,7 @@ def test_check_order( Each combination triggers exactly two failure causes. The test derives the expected outcome from the higher-priority failure. """ - mem_end = _calldata_mem_end(func.calldata_size) - ret_offset = max(mem_end, 96) + ret_offset = calldata_mem_end(func.calldata_size) rdc_offset = ret_offset + 32 delegating_eoa: Address | None = None @@ -1083,8 +1075,7 @@ def test_check_order_triple( if not _pairwise_compatible(tuple(normalized)): pytest.skip("normalized scenarios are incompatible") - mem_end = _calldata_mem_end(func.calldata_size) - ret_offset = max(mem_end, 96) + ret_offset = calldata_mem_end(func.calldata_size) rdc_offset = ret_offset + 32 scenarios = {scenario1, scenario2, scenario3} @@ -1439,7 +1430,7 @@ def test_syscall_rejected( Op.CALL( gas=100000, address=STAKING_PRECOMPILE, - args_offset=60, + args_offset=CORRECT_SEL_ARGS_OFFSET, args_size=36, ret_offset=0, ret_size=32, From 0005f482fc8dc40e01db54cef241a3bc5000750c Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:14:40 +0000 Subject: [PATCH 17/19] Expand REPRESENTATIVE_FUNCTIONS to cover all equivalence classes Co-Authored-By: Claude claude-opus-4-6 --- tests/monad_eight/staking_precompile/spec.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/tests/monad_eight/staking_precompile/spec.py b/tests/monad_eight/staking_precompile/spec.py index a21b5705872..17f6b04ec43 100644 --- a/tests/monad_eight/staking_precompile/spec.py +++ b/tests/monad_eight/staking_precompile/spec.py @@ -300,11 +300,26 @@ class FunctionInfo: PAYABLE_FUNCTIONS = [f for f in ALL_FUNCTIONS if f.is_payable] NON_PAYABLE_FUNCTIONS = [f for f in ALL_FUNCTIONS if not f.is_payable] -# Representative subset to limit parametrization explosion +# Representative subset to limit parametrization explosion. +# One function per behavioral equivalence class: +# addValidator — payable + overrides_size_errors +# delegate — payable + nonzero_value_error +# externalReward — payable + empty_state_error (no nonzero_value_error) +# withdraw — non-payable + empty_state_error +# undelegate — non-payable, no special errors +# getEpoch — parameterless (calldata_size==4) + gas <= stipend REPRESENTATIVE_FUNCTIONS = [ f for f in ALL_FUNCTIONS - if f.name in ("delegate", "undelegate", "getEpoch", "getValidator") + if f.name + in ( + "addValidator", + "delegate", + "externalReward", + "withdraw", + "undelegate", + "getEpoch", + ) ] # Lookup table: selector -> FunctionInfo From d10557db46c8e0b7bf1df79ed2c5c7a065193fd3 Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:18:13 +0000 Subject: [PATCH 18/19] Simplify getter tests to use RETURNDATACOPY instead of ret buffer Co-Authored-By: Claude claude-opus-4-6 --- .../staking_precompile/test_getters.py | 37 ++++++++----------- 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/tests/monad_eight/staking_precompile/test_getters.py b/tests/monad_eight/staking_precompile/test_getters.py index 7ed8244b02c..ff3c9c960f9 100644 --- a/tests/monad_eight/staking_precompile/test_getters.py +++ b/tests/monad_eight/staking_precompile/test_getters.py @@ -16,7 +16,12 @@ ) from execution_testing.forks.helpers import Fork -from .helpers import build_calldata, generous_gas +from .helpers import ( + CORRECT_SEL_ARGS_OFFSET, + build_calldata, + calldata_mem_end, + generous_gas, +) from .spec import ( GETTER_FUNCTIONS, STAKING_PRECOMPILE, @@ -60,10 +65,8 @@ def test_getter_return_data( word of the return data. """ num_words = func.return_size // 32 + rdc_offset = calldata_mem_end(func.calldata_size) - # Build contract: call getter, store RETURNDATASIZE, then - # copy return data and store each word - ret_offset = 256 contract = ( build_calldata(func.selector, func.calldata_size) + Op.SSTORE( @@ -71,17 +74,14 @@ def test_getter_return_data( Op.CALL( gas=func.gas_cost + 10000, address=STAKING_PRECOMPILE, - args_offset=60, + args_offset=CORRECT_SEL_ARGS_OFFSET, args_size=func.calldata_size, - ret_offset=ret_offset, - ret_size=func.return_size, ), ) + Op.SSTORE(slot_return_size, Op.RETURNDATASIZE) ) if num_words > 0: - rdc_offset = ret_offset + func.return_size + 32 contract += Op.RETURNDATACOPY(rdc_offset, 0, Op.RETURNDATASIZE) contract += Op.SSTORE(slot_return_word_base, Op.MLOAD(rdc_offset)) @@ -132,41 +132,36 @@ def test_getter_idempotent( slot_success_1 = 0x24 slot_success_2 = 0x25 - ret_offset_1 = 256 - ret_offset_2 = ret_offset_1 + func.return_size + 64 - - calldata_setup = build_calldata(func.selector, func.calldata_size) + rdc_offset = calldata_mem_end(func.calldata_size) contract = ( - calldata_setup + build_calldata(func.selector, func.calldata_size) # First call + Op.SSTORE( slot_success_1, Op.CALL( gas=func.gas_cost + 10000, address=STAKING_PRECOMPILE, - args_offset=60, + args_offset=CORRECT_SEL_ARGS_OFFSET, args_size=func.calldata_size, - ret_offset=ret_offset_1, - ret_size=func.return_size, ), ) + Op.SSTORE(slot_size_1, Op.RETURNDATASIZE) - + Op.SSTORE(slot_word_1, Op.MLOAD(ret_offset_1)) + + Op.RETURNDATACOPY(rdc_offset, 0, Op.RETURNDATASIZE) + + Op.SSTORE(slot_word_1, Op.MLOAD(rdc_offset)) # Second call (same calldata still in memory) + Op.SSTORE( slot_success_2, Op.CALL( gas=func.gas_cost + 10000, address=STAKING_PRECOMPILE, - args_offset=60, + args_offset=CORRECT_SEL_ARGS_OFFSET, args_size=func.calldata_size, - ret_offset=ret_offset_2, - ret_size=func.return_size, ), ) + Op.SSTORE(slot_size_2, Op.RETURNDATASIZE) - + Op.SSTORE(slot_word_2, Op.MLOAD(ret_offset_2)) + + Op.RETURNDATACOPY(rdc_offset, 0, Op.RETURNDATASIZE) + + Op.SSTORE(slot_word_2, Op.MLOAD(rdc_offset)) + Op.SSTORE(slot_code_worked, value_code_worked) ) contract_address = pre.deploy_contract(contract) From 9b29e9cdbded6433d4ffa81fa7cc9ab56dea1b5f Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Fri, 13 Mar 2026 16:06:12 +0000 Subject: [PATCH 19/19] refactor: polishes to clarity and consistency Co-Authored-By: Claude --- .../forks/monad_eight/vm/precompiled_contracts/staking.py | 4 +++- .../forks/monad_nine/vm/precompiled_contracts/staking.py | 4 +++- tests/monad_eight/staking_precompile/test_fork_transition.py | 4 ++-- tests/monad_eight/staking_precompile/test_precompile_call.py | 3 ++- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/ethereum/forks/monad_eight/vm/precompiled_contracts/staking.py b/src/ethereum/forks/monad_eight/vm/precompiled_contracts/staking.py index 7baa0601293..3197870f7c6 100644 --- a/src/ethereum/forks/monad_eight/vm/precompiled_contracts/staking.py +++ b/src/ethereum/forks/monad_eight/vm/precompiled_contracts/staking.py @@ -357,7 +357,7 @@ def staking(evm: Evm) -> None: evm.output = b"value is nonzero" raise RevertInMonadPrecompile - # Validate calldata size (addValidator does ABI validation instead) + # Validate calldata size (addValidator defers to its handler) if selector != SELECTOR_ADD_VALIDATOR: if len(data) < expected_size: evm.output = b"input too short" @@ -394,5 +394,7 @@ def staking(evm: Evm) -> None: elif selector == SELECTOR_WITHDRAW: evm.output = b"unknown withdrawal id" raise RevertInMonadPrecompile + else: + raise AssertionError(f"unhandled setter: {selector.hex()}") else: raise InvalidParameter diff --git a/src/ethereum/forks/monad_nine/vm/precompiled_contracts/staking.py b/src/ethereum/forks/monad_nine/vm/precompiled_contracts/staking.py index 7baa0601293..3197870f7c6 100644 --- a/src/ethereum/forks/monad_nine/vm/precompiled_contracts/staking.py +++ b/src/ethereum/forks/monad_nine/vm/precompiled_contracts/staking.py @@ -357,7 +357,7 @@ def staking(evm: Evm) -> None: evm.output = b"value is nonzero" raise RevertInMonadPrecompile - # Validate calldata size (addValidator does ABI validation instead) + # Validate calldata size (addValidator defers to its handler) if selector != SELECTOR_ADD_VALIDATOR: if len(data) < expected_size: evm.output = b"input too short" @@ -394,5 +394,7 @@ def staking(evm: Evm) -> None: elif selector == SELECTOR_WITHDRAW: evm.output = b"unknown withdrawal id" raise RevertInMonadPrecompile + else: + raise AssertionError(f"unhandled setter: {selector.hex()}") else: raise InvalidParameter diff --git a/tests/monad_eight/staking_precompile/test_fork_transition.py b/tests/monad_eight/staking_precompile/test_fork_transition.py index ea0f44204df..0a18fb248c7 100644 --- a/tests/monad_eight/staking_precompile/test_fork_transition.py +++ b/tests/monad_eight/staking_precompile/test_fork_transition.py @@ -18,7 +18,7 @@ from execution_testing.forks.forks.forks import MONAD_EIGHT from execution_testing.forks.helpers import Fork -from .helpers import build_calldata, generous_gas +from .helpers import CORRECT_SEL_ARGS_OFFSET, build_calldata, generous_gas from .spec import ( GAS_GET_PROPOSER_VAL_ID, SELECTOR_GET_PROPOSER_VAL_ID, @@ -55,7 +55,7 @@ def test_fork_transition( + Op.CALL( gas=GAS_GET_PROPOSER_VAL_ID + 10000, address=STAKING_PRECOMPILE, - args_offset=60, + args_offset=CORRECT_SEL_ARGS_OFFSET, args_size=4, ret_offset=0, ret_size=32, diff --git a/tests/monad_eight/staking_precompile/test_precompile_call.py b/tests/monad_eight/staking_precompile/test_precompile_call.py index bd14618f74a..7b3be729e7f 100644 --- a/tests/monad_eight/staking_precompile/test_precompile_call.py +++ b/tests/monad_eight/staking_precompile/test_precompile_call.py @@ -34,6 +34,7 @@ from .helpers import ( CORRECT_SEL_ARGS_OFFSET, + CORRECT_SEL_MSTORE_OFFSET, WRONG_SEL_ARGS_OFFSET, WRONG_SEL_MSTORE_OFFSET, build_calldata, @@ -532,7 +533,7 @@ def test_selector( args_size = func.calldata_size gas = func.gas_cost + 10000 else: - calldata_setup = Op.MSTORE(32, selector) + calldata_setup = Op.MSTORE(CORRECT_SEL_MSTORE_OFFSET, selector) args_size = 4 gas = GAS_UNKNOWN_SELECTOR + 10000