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..3197870f7c6 --- /dev/null +++ b/src/ethereum/forks/monad_eight/vm/precompiled_contracts/staking.py @@ -0,0 +1,400 @@ +""" +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.numeric import U256, Uint + +from ...vm import Evm +from ...vm.exceptions import InvalidParameter, RevertInMonadPrecompile +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(289325) +GAS_CLAIM_REWARDS = Uint(155375) +GAS_CHANGE_COMMISSION = Uint(39475) +GAS_EXTERNAL_REWARD = Uint(66575) +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(200) +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 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 + 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=0, in_boundary_delay=false. + """ + # Returns (uint64 epoch, bool inBoundaryDelay) + 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=0 (no validators registered). + """ + # Returns uint64 + evm.output = _abi_encode_uint256(0) + + +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 * 18 + + +def _handle_get_delegator(evm: Evm) -> None: + """ + Handle getDelegator(uint64,address) call. + + Return stub: a zeroed-out delegator structure. + """ + evm.output = b"\x00" * 32 * 7 + + +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 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) + evm.output = b"method not supported" + raise RevertInMonadPrecompile + + selector = bytes(data[:4]) + + # Look up selector info + info = _SELECTOR_INFO.get(selector) + if info is None: + charge_gas(evm, GAS_UNKNOWN_SELECTOR) + evm.output = b"method not supported" + raise RevertInMonadPrecompile + + gas_cost, is_payable, expected_size = info + + # GAS + charge_gas(evm, gas_cost) + + # Syscall selectors are always rejected from regular user calls + if selector in _SYSCALL_SELECTORS: + evm.output = b"method not supported" + raise RevertInMonadPrecompile + + # Non-payable functions reject nonzero value + if not is_payable and evm.message.value != 0: + evm.output = b"value is nonzero" + raise RevertInMonadPrecompile + + # Validate calldata size (addValidator defers to its handler) + 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: + handler = _GETTER_HANDLERS[selector] + handler(evm) # type: ignore[operator] + elif selector in _SETTER_SELECTORS: + if selector == SELECTOR_ADD_VALIDATOR: + 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, + SELECTOR_COMPOUND, + SELECTOR_CLAIM_REWARDS, + ): + evm.output = _abi_encode_bool(True) + 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 AssertionError(f"unhandled setter: {selector.hex()}") + 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..3197870f7c6 --- /dev/null +++ b/src/ethereum/forks/monad_nine/vm/precompiled_contracts/staking.py @@ -0,0 +1,400 @@ +""" +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.numeric import U256, Uint + +from ...vm import Evm +from ...vm.exceptions import InvalidParameter, RevertInMonadPrecompile +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(289325) +GAS_CLAIM_REWARDS = Uint(155375) +GAS_CHANGE_COMMISSION = Uint(39475) +GAS_EXTERNAL_REWARD = Uint(66575) +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(200) +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 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 + 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=0, in_boundary_delay=false. + """ + # Returns (uint64 epoch, bool inBoundaryDelay) + 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=0 (no validators registered). + """ + # Returns uint64 + evm.output = _abi_encode_uint256(0) + + +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 * 18 + + +def _handle_get_delegator(evm: Evm) -> None: + """ + Handle getDelegator(uint64,address) call. + + Return stub: a zeroed-out delegator structure. + """ + evm.output = b"\x00" * 32 * 7 + + +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 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) + evm.output = b"method not supported" + raise RevertInMonadPrecompile + + selector = bytes(data[:4]) + + # Look up selector info + info = _SELECTOR_INFO.get(selector) + if info is None: + charge_gas(evm, GAS_UNKNOWN_SELECTOR) + evm.output = b"method not supported" + raise RevertInMonadPrecompile + + gas_cost, is_payable, expected_size = info + + # GAS + charge_gas(evm, gas_cost) + + # Syscall selectors are always rejected from regular user calls + if selector in _SYSCALL_SELECTORS: + evm.output = b"method not supported" + raise RevertInMonadPrecompile + + # Non-payable functions reject nonzero value + if not is_payable and evm.message.value != 0: + evm.output = b"value is nonzero" + raise RevertInMonadPrecompile + + # Validate calldata size (addValidator defers to its handler) + 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: + handler = _GETTER_HANDLERS[selector] + handler(evm) # type: ignore[operator] + elif selector in _SETTER_SELECTORS: + if selector == SELECTOR_ADD_VALIDATOR: + 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, + SELECTOR_COMPOUND, + SELECTOR_CLAIM_REWARDS, + ): + evm.output = _abi_encode_bool(True) + 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 AssertionError(f"unhandled setter: {selector.hex()}") + 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..ef43f9b968f --- /dev/null +++ b/tests/monad_eight/staking_precompile/helpers.py @@ -0,0 +1,68 @@ +"""Helper functions for staking precompile tests.""" + +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.""" + 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 + 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 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 in memory. + + 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. + """ + # 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/spec.py b/tests/monad_eight/staking_precompile/spec.py new file mode 100644 index 00000000000..17f6b04ec43 --- /dev/null +++ b/tests/monad_eight/staking_precompile/spec.py @@ -0,0 +1,326 @@ +"""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") + +# 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" +ERROR_LENGTH_MISMATCH = "length mismatch" + +# 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 = 289325 +GAS_CLAIM_REWARDS = 155375 +GAS_CHANGE_COMMISSION = 39475 +GAS_EXTERNAL_REWARD = 66575 +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 = 200 +GAS_GET_PROPOSER_VAL_ID = 100 +GAS_UNKNOWN_SELECTOR = 40000 + +# Expected calldata sizes (selector + ABI-encoded params) +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 +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 + empty_state_error: str = "" + overrides_size_errors: bool = False + nonzero_value_error: str = "" + + +# All functions with their metadata +ALL_FUNCTIONS = [ + # Setters + FunctionInfo( + SELECTOR_ADD_VALIDATOR, + GAS_ADD_VALIDATOR, + CALLDATA_SIZE_ADD_VALIDATOR, + True, + "addValidator", + 0, + 0, + empty_state_error=ERROR_LENGTH_MISMATCH, + overrides_size_errors=True, + ), + FunctionInfo( + SELECTOR_DELEGATE, + GAS_DELEGATE, + CALLDATA_SIZE_DELEGATE, + True, + "delegate", + 32, + 1, + nonzero_value_error=ERROR_UNKNOWN_VALIDATOR, + ), + FunctionInfo( + SELECTOR_UNDELEGATE, + GAS_UNDELEGATE, + CALLDATA_SIZE_UNDELEGATE, + False, + "undelegate", + 32, + 1, + ), + FunctionInfo( + SELECTOR_WITHDRAW, + GAS_WITHDRAW, + CALLDATA_SIZE_WITHDRAW, + False, + "withdraw", + 0, + 0, + empty_state_error=ERROR_UNKNOWN_WITHDRAWAL_ID, + ), + FunctionInfo( + SELECTOR_COMPOUND, + GAS_COMPOUND, + CALLDATA_SIZE_COMPOUND, + False, + "compound", + 32, + 1, + ), + FunctionInfo( + SELECTOR_CLAIM_REWARDS, + GAS_CLAIM_REWARDS, + CALLDATA_SIZE_CLAIM_REWARDS, + False, + "claimRewards", + 32, + 1, + ), + FunctionInfo( + SELECTOR_CHANGE_COMMISSION, + GAS_CHANGE_COMMISSION, + CALLDATA_SIZE_CHANGE_COMMISSION, + False, + "changeCommission", + 0, + 0, + empty_state_error=ERROR_UNKNOWN_VALIDATOR, + ), + FunctionInfo( + SELECTOR_EXTERNAL_REWARD, + GAS_EXTERNAL_REWARD, + CALLDATA_SIZE_EXTERNAL_REWARD, + True, + "externalReward", + 0, + 0, + empty_state_error=ERROR_UNKNOWN_VALIDATOR, + ), + # Getters + FunctionInfo( + SELECTOR_GET_VALIDATOR, + GAS_GET_VALIDATOR, + CALLDATA_SIZE_GET_VALIDATOR, + False, + "getValidator", + 32 * 18, # 18 zero words + 0, + ), + FunctionInfo( + SELECTOR_GET_DELEGATOR, + GAS_GET_DELEGATOR, + CALLDATA_SIZE_GET_DELEGATOR, + False, + "getDelegator", + 32 * 7, # 7 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 + 0, # epoch=0 (no epochs occurred) + ), + FunctionInfo( + SELECTOR_GET_PROPOSER_VAL_ID, + GAS_GET_PROPOSER_VAL_ID, + CALLDATA_SIZE_GET_PROPOSER_VAL_ID, + False, + "getProposerValId", + 32, # uint64 + 0, # validator_id=0 (no validators registered) + ), +] + +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. +# 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 ( + "addValidator", + "delegate", + "externalReward", + "withdraw", + "undelegate", + "getEpoch", + ) +] + +# 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..0a18fb248c7 --- /dev/null +++ b/tests/monad_eight/staking_precompile/test_fork_transition.py @@ -0,0 +1,129 @@ +""" +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 import get_transition_fork_predecessor +from execution_testing.forks.forks.forks import MONAD_EIGHT +from execution_testing.forks.helpers import Fork + +from .helpers import CORRECT_SEL_ARGS_OFFSET, 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() + + # 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=CORRECT_SEL_ARGS_OFFSET, + 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=Op.GAS, address=callee_address), + ), + storage={14_999: "0xdeadbeef"}, + ) + blocks = [ + Block( + timestamp=14_999, + txs=[ + Transaction( + to=caller_address, + sender=sender, + nonce=0, + gas_limit=generous_gas(fork), + ) + ], + ), + Block( + timestamp=15_000, + txs=[ + Transaction( + to=caller_address, + sender=sender, + nonce=1, + gas_limit=generous_gas(fork), + ) + ], + ), + Block( + timestamp=15_001, + txs=[ + Transaction( + to=caller_address, + sender=sender, + nonce=2, + gas_limit=generous_gas(fork), + ) + ], + ), + ] + blockchain_test( + pre=pre, + blocks=blocks, + post={ + caller_address: Account(storage={14_999: 1, 15_000: 1, 15_001: 1}), + callee_address: Account( + storage={ + # 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 + 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..ff3c9c960f9 --- /dev/null +++ b/tests/monad_eight/staking_precompile/test_getters.py @@ -0,0 +1,191 @@ +""" +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 ( + CORRECT_SEL_ARGS_OFFSET, + build_calldata, + calldata_mem_end, + 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 + rdc_offset = calldata_mem_end(func.calldata_size) + + 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=CORRECT_SEL_ARGS_OFFSET, + args_size=func.calldata_size, + ), + ) + + Op.SSTORE(slot_return_size, Op.RETURNDATASIZE) + ) + + if num_words > 0: + contract += Op.RETURNDATACOPY(rdc_offset, 0, Op.RETURNDATASIZE) + contract += Op.SSTORE(slot_return_word_base, Op.MLOAD(rdc_offset)) + + 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, + } + + if num_words > 0: + storage[slot_return_word_base] = func.first_return_word + + 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 + + rdc_offset = calldata_mem_end(func.calldata_size) + + contract = ( + 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=CORRECT_SEL_ARGS_OFFSET, + args_size=func.calldata_size, + ), + ) + + Op.SSTORE(slot_size_1, Op.RETURNDATASIZE) + + 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=CORRECT_SEL_ARGS_OFFSET, + args_size=func.calldata_size, + ), + ) + + Op.SSTORE(slot_size_2, Op.RETURNDATASIZE) + + 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) + + tx = Transaction( + gas_limit=generous_gas(fork) + 2 * func.gas_cost, + 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 new file mode 100644 index 00000000000..7b3be729e7f --- /dev/null +++ b/tests/monad_eight/staking_precompile/test_precompile_call.py @@ -0,0 +1,1463 @@ +""" +Tests for (stubbed and empty state-ed) 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 __future__ import annotations + +from dataclasses import dataclass +from enum import Enum, auto, unique +from typing import Any + +import pytest +from execution_testing import ( + Account, + Address, + Alloc, + AuthorizationTuple, + Block, + BlockchainTestFiller, + Bytecode, + Op, + 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 + +from .helpers import ( + CORRECT_SEL_ARGS_OFFSET, + CORRECT_SEL_MSTORE_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, + ERROR_INVALID_INPUT, + ERROR_METHOD_NOT_SUPPORTED, + ERROR_VALUE_NONZERO, + FUNC_BY_SELECTOR, + GAS_UNKNOWN_SELECTOR, + REPRESENTATIVE_FUNCTIONS, + STAKING_PRECOMPILE, + FunctionInfo, + ref_spec_staking, +) + +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 +slot_return_size = 0x3 +slot_return_value = 0x4 +slot_ret_buffer_value = 0x5 +slot_all_gas_consumed = 0x6 + + +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") + + +@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.""" + + SUCCESS = auto() + TRUNCATED_SELECTOR = auto() + WRONG_SELECTOR = auto() + SHORT_CALLDATA = auto() + EXTRA_CALLDATA = auto() + NOT_CALL = auto() + DELEGATE_TO_PRECOMPILE = auto() + NONZERO_VALUE = auto() + LOW_GAS = auto() + + @property + def check_priority(self) -> int: + """Return precompile 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, + CallScenario.SUCCESS, + ] + return order.index(self) + + +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 _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, + 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 + + if _stipend_neutralizes_low_gas( + # True iff `scenario1 == LOW_GAS and scenario2 == NONZERO_VALUE` + func, + {scenario1, scenario2}, + ): + 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 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, + gas: int | Bytecode | None = None, + 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) + + setup: Bytecode = build_calldata( + func.selector, func.calldata_size + ) + Op.MSTORE(WRONG_SEL_MSTORE_OFFSET, 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 gas is None: + if CallScenario.LOW_GAS in scenario_set: + 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) + + 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 = [ + pytest.mark.valid_from("MONAD_EIGHT"), + pytest.mark.pre_alloc_group( + "staking_precompile_tests", + reason="Tests staking precompile", + ), +] + + +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_by_fork("input_size_delta", input_size_delta) +def test_input_size( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + func: FunctionInfo, + input_size_delta: int, + fork: Fork, +) -> None: + """ + Test precompile behavior with various input sizes. + + Calldata must be exactly the expected size for the function. + """ + input_size = func.calldata_size + input_size_delta + if input_size < 4: + gas = GAS_UNKNOWN_SELECTOR + 10000 + else: + gas = func.gas_cost + 10000 + calldata_setup = build_calldata( + func.selector, func.calldata_size + input_size_delta + ) + + contract = ( + calldata_setup + + Op.SSTORE( + slot_call_success, + Op.CALL( + gas=gas, + address=STAKING_PRECOMPILE, + args_offset=CORRECT_SEL_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(), + ) + + outcome = ExpectedOutcome.by_input_size_delta(func, input_size_delta) + + blockchain_test( + pre=pre, + post={ + contract_address: Account( + storage={ + slot_call_success: outcome.call_success, + slot_return_size: outcome.return_size, + 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 + else: + calldata_setup = Op.MSTORE(CORRECT_SEL_MSTORE_OFFSET, selector) + args_size = 4 + gas = GAS_UNKNOWN_SELECTOR + 10000 + + contract = ( + calldata_setup + + Op.SSTORE( + slot_call_success, + Op.CALL( + gas=gas, + address=STAKING_PRECOMPILE, + args_offset=CORRECT_SEL_ARGS_OFFSET, + 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(), + ) + + outcome = ExpectedOutcome.by_selector(selector) + + blockchain_test( + pre=pre, + post={ + contract_address: Account( + storage={ + slot_call_success: outcome.call_success, + slot_return_size: outcome.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=CORRECT_SEL_ARGS_OFFSET, + 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), + ) + + outcome = ( + ExpectedOutcome.by_function(func) + if enough_gas + else ExpectedOutcome.revert("") + ) + + state_test( + pre=pre, + post={ + contract_address: Account( + storage={ + slot_call_success: outcome.call_success, + 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=CORRECT_SEL_ARGS_OFFSET, + 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(), + ) + + outcome = ExpectedOutcome.by_call_opcode(func, call_opcode) + + blockchain_test( + pre=pre, + post={ + contract_address: Account( + storage={ + slot_call_success: outcome.call_success, + slot_return_size: outcome.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( + "scenario", + list(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 + + 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( + scenario, + func=func, + ret_offset=ret_offset, + ret_size=32, + delegating_eoa=delegating_eoa, + ), + ) + + Op.SSTORE(slot_return_size, Op.RETURNDATASIZE) + + Op.SSTORE(slot_ret_buffer_value, Op.MLOAD(ret_offset)) + + Op.RETURNDATACOPY(rdc_offset, 0, Op.RETURNDATASIZE) + + Op.SSTORE(slot_return_value, Op.MLOAD(rdc_offset)) + + Op.SSTORE(slot_code_worked, value_code_worked) + ) + contract_address = pre.deploy_contract(contract, balance=1) + + tx = Transaction( + gas_limit=generous_gas(fork), + to=contract_address, + sender=pre.fund_eoa(), + authorization_list=authorization_list, + ) + + outcome = resolve_outcome(func, scenario) + + blockchain_test( + pre=pre, + post={ + contract_address: Account( + storage={ + 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, + } + ), + }, + blocks=[Block(txs=[tx])], + ) + + +@pytest.mark.parametrize( + "func", + [pytest.param(f, id=f.name) for f in ALL_FUNCTIONS], +) +@pytest.mark.parametrize( + "scenario", + [s for s in CallScenario if s != CallScenario.LOW_GAS], +) +def test_revert_consumes_all_gas( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, + 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 + + 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( + scenario, + func=func, + gas=Op.GAS, + delegating_eoa=delegating_eoa, + ), + ) + + Op.SSTORE( + slot_all_gas_consumed, + Op.LT(Op.GAS, gas_threshold), + ) + ) + contract_address = pre.deploy_contract(contract, balance=1) + + tx = Transaction( + gas_limit=gas_limit, + to=contract_address, + sender=pre.fund_eoa(), + authorization_list=authorization_list, + ) + + outcome = resolve_outcome(func, scenario) + + blockchain_test( + pre=pre, + post={ + contract_address: Account( + storage={ + slot_code_worked: value_code_worked, + slot_call_success: outcome.call_success, + slot_all_gas_consumed: 1 - outcome.call_success, + } + ), + }, + blocks=[Block(txs=[tx])], + ) + + +@pytest.mark.parametrize( + "func", + [pytest.param(f, id=f.name) for f in ALL_FUNCTIONS], +) +@pytest.mark.parametrize("value", [0, 1, 2**128]) +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". + """ + ret_offset = calldata_mem_end(func.calldata_size) + 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=CORRECT_SEL_ARGS_OFFSET, + 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(), + ) + + outcome = ExpectedOutcome.by_value(func, value) + + post: dict = { + 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, + }, + balance=0 if outcome.call_success else value, + ), + STAKING_PRECOMPILE: Account(balance=value) + if outcome.call_success and value > 0 + else None, + } + + blockchain_test( + pre=pre, + post=post, + blocks=[Block(txs=[tx])], + ) + + +# --- Check-order tests and their helpers --- + +_INCOMPATIBLE_SCENARIOS = { + frozenset({CallScenario.SHORT_CALLDATA, CallScenario.EXTRA_CALLDATA}), + frozenset({CallScenario.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. + """ + ret_offset = calldata_mem_end(func.calldata_size) + 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, + scenario_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, + ) + + outcome = resolve_outcome_pair(func, scenario1, scenario2) + + 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])], + ) + + +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 + 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") + + ret_offset = calldata_mem_end(func.calldata_size) + 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 --- + + +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) + + outcome = resolve_outcome(func, scenario) + + 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=outcome.call_success, + ), + ) + + if outcome.call_success: + post: dict = { + sender: Account(balance=0), + STAKING_PRECOMPILE: Account(balance=value) if value > 0 else None, + } + else: + post = {sender: Account(balance=value)} + + state_test( + pre=pre, + post=post, + tx=tx, + ) + + +_TX_INCOMPATIBLE_SCENARIOS = _INCOMPATIBLE_SCENARIOS | { + # EIP-7623 floor makes it impossible to create a valid tx with + # insufficient execution gas when extra calldata is appended. + # For extra calldata (5 bytes), the floor is high enough that + # we can't create a valid tx with less than 100 execution gas + # EIP-7623 floor (21200) vs (21179) - impossible + # For correct calldata (4 bytes), in the test just above it's + # EIP-7623 floor (21160) vs (21163) - possible + frozenset({CallScenario.LOW_GAS, CallScenario.EXTRA_CALLDATA}), +} + +_TX_SCENARIO_PAIRS = [ + pytest.param(s1, s2, id=f"{s1.name.lower()}__{s2.name.lower()}") + for s1 in CallScenario + for s2 in CallScenario + if CallScenario.SUCCESS not in {s1, s2} + and CallScenario.NOT_CALL not in {s1, s2} + and s1.check_priority < s2.check_priority + and frozenset({s1, s2}) not in _TX_INCOMPATIBLE_SCENARIOS +] + + +@pytest.mark.parametrize( + "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) + + outcome1 = resolve_outcome(func, scenario1) + outcome2 = resolve_outcome(func, scenario2) + + 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 + else 0x0 + ), + ) + + post: dict = { + sender: Account(balance=value), + } + + state_test( + pre=pre, + post=post, + tx=tx, + ) + + +_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", + [ + 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=CORRECT_SEL_ARGS_OFFSET, + 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: len(ERROR_METHOD_NOT_SUPPORTED), + slot_code_worked: value_code_worked, + } + ), + }, + blocks=[Block(txs=[tx])], + )