From 69907c80652b5c95eb01286ced1fbc4e9f407474 Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:06:43 +0000 Subject: [PATCH 1/4] feat(mip11): standalone staking precompile + MIP-11 distribution on monad_nine Port monad_next staking precompile, MIP-11 fee redirect/distribution, the t8n hooks, and the mip11 tests onto forks/monad_nine; real MIP-11 supersedes the priority-fee burn shim. Co-Authored-By: Claude --- src/ethereum/forks/monad_next/fork.py | 145 +++- .../vm/precompiled_contracts/mapping.py | 3 + .../vm/precompiled_contracts/staking.py | 754 ++++++++++++++++++ .../evm_tools/loaders/fork_loader.py | 20 + .../evm_tools/t8n/__init__.py | 6 + .../__init__.py | 1 + .../conftest.py | 1 + .../helpers.py | 50 ++ .../mip11_priority_fee_distribution/spec.py | 161 ++++ .../test_distribution.py | 345 ++++++++ .../test_fee5.py | 210 +++++ .../test_fork_transition.py | 80 ++ .../test_multi_block.py | 123 +++ 13 files changed, 1888 insertions(+), 11 deletions(-) create mode 100644 src/ethereum/forks/monad_next/vm/precompiled_contracts/staking.py create mode 100644 tests/monad_ten/mip11_priority_fee_distribution/__init__.py create mode 100644 tests/monad_ten/mip11_priority_fee_distribution/conftest.py create mode 100644 tests/monad_ten/mip11_priority_fee_distribution/helpers.py create mode 100644 tests/monad_ten/mip11_priority_fee_distribution/spec.py create mode 100644 tests/monad_ten/mip11_priority_fee_distribution/test_distribution.py create mode 100644 tests/monad_ten/mip11_priority_fee_distribution/test_fee5.py create mode 100644 tests/monad_ten/mip11_priority_fee_distribution/test_fork_transition.py create mode 100644 tests/monad_ten/mip11_priority_fee_distribution/test_multi_block.py diff --git a/src/ethereum/forks/monad_next/fork.py b/src/ethereum/forks/monad_next/fork.py index 06e3a5fabb..90460ab38b 100644 --- a/src/ethereum/forks/monad_next/fork.py +++ b/src/ethereum/forks/monad_next/fork.py @@ -15,7 +15,7 @@ from typing import AbstractSet, Dict, Final, List, Optional, Tuple, final from ethereum_rlp import rlp -from ethereum_types.bytes import Bytes, Bytes32 +from ethereum_types.bytes import Bytes, Bytes0, Bytes32 from ethereum_types.numeric import U64, U256, Uint from ethereum.crypto.hash import Hash32, keccak256 @@ -68,6 +68,7 @@ from .state_tracker import ( BlockState, TransactionState, + account_exists, add_sender_authority, create_ether, destroy_account, @@ -104,6 +105,11 @@ calculate_total_blob_gas, ) from .vm.interpreter import MessageCallOutput, process_message_call +from .vm.precompiled_contracts import STAKING_ADDRESS, staking +from .vm.precompiled_contracts.staking import ( + FEE_DISTRIBUTION_ADDRESS, + SYSTEM_SENDER, +) BASE_FEE_MAX_CHANGE_DENOMINATOR = Uint(8) ELASTICITY_MULTIPLIER = Uint(2) @@ -821,6 +827,8 @@ def apply_body( block_output = vm.BlockOutput() forget_senders_authorities(block_env.state, block_env.number) + execute_block_prelude(block_env) + process_unchecked_system_transaction( block_env=block_env, target_address=BEACON_ROOTS_ADDRESS, @@ -843,9 +851,41 @@ def apply_body( block_output=block_output, ) + distribute_priority_fees(block_env) + return block_output +def execute_block_prelude(block_env: vm.BlockEnvironment) -> None: + """ + Clear the proposer validator id at the start of the block (MIP-11). + + The proposer is re-established only by a reward syscall transaction; + absent one, end-of-block distribution finds no proposer and burns + the accumulated balance. + """ + prelude_state = TransactionState(parent=block_env.state) + if account_exists(prelude_state, STAKING_ADDRESS): + staking.clear_proposer_val_id(prelude_state) + incorporate_tx_into_block(prelude_state) + + +def distribute_priority_fees(block_env: vm.BlockEnvironment) -> None: + """ + Distribute the priority fees accumulated this block (MIP-11). + + Direct end-of-block call into the staking distribution logic. The + distribution is not a transaction; it credits the proposer pool and + empties the distribution account. The client emits a ValidatorRewarded + log here to its event stream, but never into a receipt, so it stays + out of the header bloom (``compute_bloom`` runs over receipts only); + the log is therefore not surfaced to the block's logs. + """ + dist_state = TransactionState(parent=block_env.state) + staking.distribute_priority_fees(dist_state) + incorporate_tx_into_block(dist_state) + + def process_general_purpose_requests( block_env: vm.BlockEnvironment, block_output: vm.BlockOutput, @@ -929,6 +969,14 @@ def process_transaction( encode_transaction(tx), ) + # Staking syscalls are block transactions signed by the system sender. + # They pay no gas and mint their value; route them to the system path. + if recover_sender(block_env.chain_id, tx) == SYSTEM_SENDER: + process_staking_system_transaction( + block_env, block_output, tx, index, tx_state + ) + return + intrinsic_gas, calldata_floor_gas_cost = validate_transaction(tx) ( @@ -1009,21 +1057,17 @@ def process_transaction( # gas_refund_amount = tx_gas_left * effective_gas_price # For non-1559 transactions effective_gas_price == tx.gas_price - # NOTE: commented out, see MIP-11 shim below - # TODO: uncomment and adjust after MIP-11 lands here - # priority_fee_per_gas = effective_gas_price - block_env.base_fee_per_gas - # transaction_fee = tx.gas * priority_fee_per_gas + priority_fee_per_gas = effective_gas_price - block_env.base_fee_per_gas + transaction_fee = tx.gas * priority_fee_per_gas # Monad: gas is not refunded to the sender (note absence of # create_ether(tx_state, sender, U256(gas_refund_amount)) here). add_sender_authority(block_env.state, block_env.number, sender) - # MIP-11 shim: priority fees are routed to the staking distribution - # address and paid out to the proposer's validator and delegators. In - # tests no proposer is wired up, so distribution finds an unknown - # validator and the fees are burned rather than credited to the - # coinbase. Burn them here so post-state roots match a client that - # implements MIP-11 fully. + # MIP-11: priority fees are credited to the distribution account + # instead of the block beneficiary; an end-of-block syscall later + # distributes them to the leader's delegators. + create_ether(tx_state, FEE_DISTRIBUTION_ADDRESS, U256(transaction_fee)) for address in tx_output.accounts_to_delete: destroy_account(tx_state, address) @@ -1050,6 +1094,85 @@ def process_transaction( incorporate_tx_into_block(tx_state) +def process_staking_system_transaction( + block_env: vm.BlockEnvironment, + block_output: vm.BlockOutput, + tx: Transaction, + index: Uint, + tx_state: TransactionState, +) -> None: + """ + Execute a staking syscall transaction from the system sender. + + Unlike user transactions these pay no gas (they consume no block + gas), always succeed, increment the sender nonce, and mint their + value rather than transferring it. The staking precompile dispatches + the syscall by selector (see ``_syscall_reward``). + + System transactions declare no gas: a nonzero gas limit or gas price + is a block error, matching the client's system-transaction rules. + """ + sender = SYSTEM_SENDER + + if not isinstance(tx, LegacyTransaction): + raise InvalidBlock("system transaction must be legacy") + if tx.gas != Uint(0) or tx.gas_price != Uint(0): + raise InvalidBlock("system transaction gas non zero") + + increment_nonce(tx_state, sender) + + assert not isinstance(tx.to, Bytes0), "system transaction must have a to" + target = tx.to + + tx_env = vm.TransactionEnvironment( + origin=sender, + gas_price=block_env.base_fee_per_gas, + gas=SYSTEM_TRANSACTION_GAS, + tx_gas_limit=SYSTEM_TRANSACTION_GAS, + access_list_addresses=set(), + access_list_storage_keys=set(), + state=tx_state, + blob_versioned_hashes=(), + authorizations=(), + index_in_block=index, + tx_hash=get_transaction_hash(encode_transaction(tx)), + ) + + message = Message( + block_env=block_env, + tx_env=tx_env, + caller=sender, + target=target, + gas=SYSTEM_TRANSACTION_GAS, + value=tx.value, + data=tx.data, + code=get_code(tx_state, get_account(tx_state, target).code_hash), + depth=Uint(0), + current_target=target, + code_address=target, + should_transfer_value=False, + is_static=False, + accessed_addresses=set(), + accessed_storage_keys=set(), + disable_precompiles=False, + parent_evm=None, + disable_create_opcodes=False, + ) + + tx_output = process_message_call(message) + + # System transactions consume no block gas. + receipt = make_receipt( + tx, tx_output.error, block_output.block_gas_used, tx_output.logs + ) + receipt_key = rlp.encode(Uint(index)) + block_output.receipt_keys += (receipt_key,) + trie_set(block_output.receipts_trie, receipt_key, receipt) + block_output.block_logs += tx_output.logs + + incorporate_tx_into_block(tx_state) + + def process_withdrawals( block_env: vm.BlockEnvironment, block_output: vm.BlockOutput, diff --git a/src/ethereum/forks/monad_next/vm/precompiled_contracts/mapping.py b/src/ethereum/forks/monad_next/vm/precompiled_contracts/mapping.py index 9ff150e9c2..f31aa15b23 100644 --- a/src/ethereum/forks/monad_next/vm/precompiled_contracts/mapping.py +++ b/src/ethereum/forks/monad_next/vm/precompiled_contracts/mapping.py @@ -35,6 +35,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 @@ -57,6 +58,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, @@ -77,5 +79,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_next/vm/precompiled_contracts/staking.py b/src/ethereum/forks/monad_next/vm/precompiled_contracts/staking.py new file mode 100644 index 0000000000..81ffb830c9 --- /dev/null +++ b/src/ethereum/forks/monad_next/vm/precompiled_contracts/staking.py @@ -0,0 +1,754 @@ +""" +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 ethereum.crypto.hash import Hash32 +from ethereum.state import Address + +from ...blocks import Log +from ...state_tracker import ( + TransactionState, + create_ether, + get_account, + get_storage, + set_account_balance, + set_storage, +) +from ...utils.hexadecimal import hex_to_address +from ...vm import Evm +from ...vm.exceptions import InvalidParameter, RevertInMonadPrecompile +from ...vm.gas import charge_gas +from . import STAKING_ADDRESS + +# Sender of staking syscall transactions (reward/snapshot/epoch), signed +# with a fixed, publicly known key. +SYSTEM_SENDER = hex_to_address("0x6f49a8f621353f12378d0046e7d7e4b9b249dc9e") + +# Account that accumulates priority fees during a block; the end-of-block +# distribution empties it. A plain account, not a precompile. +FEE_DISTRIBUTION_ADDRESS = hex_to_address( + "0xfee5fee5fee5fee5fee5fee5fee5fee5fee5fee5" +) + +# --- Staking state storage layout (account 0x1000) --- +# +# State is stored in the staking account's own storage using the same +# namespaced key scheme as the monad client, so produced fixtures are +# byte-compatible with it. A record's base key is a 32-byte value +# ``ns || key-fields || zero-pad``; field word ``k`` of a record lives +# at ``base_as_uint256 + k``. Small integers are left-aligned in their +# word (raw big-endian struct bytes); u256 amounts occupy the whole word. +# +# Namespaces (byte 0 of a record key): +NS_CONSENSUS_STAKE = 0x04 +NS_VAL_ID_SECP = 0x06 +NS_VAL_EXECUTION = 0x09 +NS_DELEGATOR = 0x0B + +# Singleton slots (small keys under the implicit 0x00 namespace): +KEY_EPOCH = 0x01 +KEY_LAST_VAL_ID = 0x03 +KEY_PROPOSER_VAL_ID = 0x04 + +# ValExecution record field word offsets. +VE_STAKE = 0 +VE_ACC_REWARD_PER_TOKEN = 1 +VE_COMMISSION = 2 +VE_KEYS = 3 # occupies 3 words +VE_ADDRESS_FLAGS = 6 +VE_UNCLAIMED_REWARDS = 7 + +# Delegator record field word offsets. +DEL_STAKE = 0 +DEL_ACC_REWARD_PER_TOKEN = 1 +DEL_REWARDS = 2 + +# ConsensusView record field word offsets. +CV_STAKE = 0 +CV_COMMISSION = 1 + +# Distribution constants. +MON = 10**18 +UNIT_BIAS = 10**36 +DUST_THRESHOLD = 10**9 + +_ZERO_ADDRESS = Address(b"\x00" * 20) + + +def _record_base(ns: int, body: bytes) -> int: + """Return the uint256 base key for a namespaced storage record.""" + return int.from_bytes((bytes([ns]) + body).ljust(32, b"\x00"), "big") + + +def _slot(tx_state: TransactionState, key: int) -> int: + """Read the storage word at integer key ``key`` as an int.""" + return int( + get_storage(tx_state, STAKING_ADDRESS, U256(key).to_be_bytes32()) + ) + + +def _set_slot(tx_state: TransactionState, key: int, value: int) -> None: + """Write ``value`` to the storage word at integer key ``key``.""" + set_storage( + tx_state, STAKING_ADDRESS, U256(key).to_be_bytes32(), U256(value) + ) + + +def _val_execution_base(val_id: int) -> int: + return _record_base(NS_VAL_EXECUTION, val_id.to_bytes(8, "big")) + + +def _consensus_base(val_id: int) -> int: + return _record_base(NS_CONSENSUS_STAKE, val_id.to_bytes(8, "big")) + + +def _delegator_base(val_id: int, address: Address) -> int: + return _record_base( + NS_DELEGATOR, val_id.to_bytes(8, "big") + bytes(address) + ) + + +def read_proposer_val_id(tx_state: TransactionState) -> int: + """Return the proposer validator id set this block (0 if unset).""" + return _slot(tx_state, KEY_PROPOSER_VAL_ID) >> 192 + + +def write_proposer_val_id(tx_state: TransactionState, val_id: int) -> None: + """Store the proposer validator id for this block (left-aligned u64).""" + _set_slot(tx_state, KEY_PROPOSER_VAL_ID, val_id << 192) + + +def clear_proposer_val_id(tx_state: TransactionState) -> None: + """Clear the proposer validator id (block prelude).""" + _set_slot(tx_state, KEY_PROPOSER_VAL_ID, 0) + + +def read_val_id(tx_state: TransactionState, address: Address) -> int: + """Resolve a secp address to its validator id (0 if none).""" + return _slot(tx_state, _record_base(NS_VAL_ID_SECP, bytes(address))) >> 192 + + +def read_consensus_stake(tx_state: TransactionState, val_id: int) -> int: + """Return the validator's active (consensus) stake for this epoch.""" + return _slot(tx_state, _consensus_base(val_id) + CV_STAKE) + + +def read_consensus_commission(tx_state: TransactionState, val_id: int) -> int: + """Return the validator's commission rate for this epoch.""" + return _slot(tx_state, _consensus_base(val_id) + CV_COMMISSION) + + +def read_auth_address(tx_state: TransactionState, val_id: int) -> Address: + """Return the validator's auth address (top 20 bytes of address_flags).""" + word = _slot(tx_state, _val_execution_base(val_id) + VE_ADDRESS_FLAGS) + return Address((word >> 96).to_bytes(20, "big")) + + +def validator_exists(tx_state: TransactionState, val_id: int) -> bool: + """Return whether the validator has a nonzero auth address.""" + return read_auth_address(tx_state, val_id) != _ZERO_ADDRESS + + +def _add_slot(tx_state: TransactionState, key: int, delta: int) -> None: + """Add ``delta`` to the storage word at integer key ``key``.""" + _set_slot(tx_state, key, _slot(tx_state, key) + delta) + + +def read_epoch(tx_state: TransactionState) -> int: + """Return the current staking epoch.""" + return _slot(tx_state, KEY_EPOCH) >> 192 + + +# keccak256("ValidatorRewarded(uint64,address,uint256,uint64)"). +# +# The staking API documents this event only for ``syscallReward``. +# Emitting it on the MIP-11 distribution path (with the distribution +# account as ``from``) is documented in neither MIP-11 nor the staking +# API; it is reproduced here because distribution and the reward syscall +# share the pool-crediting path, which the client instruments with this +# event. +VALIDATOR_REWARDED_TOPIC = Hash32( + bytes.fromhex( + "3a420a01486b6b28d6ae89c51f5c3bde3e0e74eecbb646a0c481ccba3aae3754" + ) +) + + +def _validator_rewarded_log( + tx_state: TransactionState, val_id: int, from_addr: Address, amount: int +) -> Log: + """Build the ValidatorRewarded log for a pool reward.""" + return Log( + address=STAKING_ADDRESS, + topics=( + VALIDATOR_REWARDED_TOPIC, + Hash32(val_id.to_bytes(32, "big")), + Hash32(bytes(12) + bytes(from_addr)), + ), + data=Bytes( + amount.to_bytes(32, "big") + + read_epoch(tx_state).to_bytes(32, "big") + ), + ) + + +def staking_distribute( + tx_state: TransactionState, + val_id: int, + from_addr: Address, + amount: int, + active_stake: int, +) -> Log: + """ + Distribute ``amount`` to a validator pool (MIP-11 + ``staking_contract.distribute(val_id)``). + + Bump ``accumulated_reward_per_token`` by ``amount * UNIT_BIAS // + active_stake`` and ``unclaimed_rewards`` by ``amount``, and return + the ValidatorRewarded log. The matching balance transfer is done by + the caller (the spec's ``{msg.value}``). + """ + base = _val_execution_base(val_id) + _add_slot( + tx_state, + base + VE_ACC_REWARD_PER_TOKEN, + amount * UNIT_BIAS // active_stake, + ) + _add_slot(tx_state, base + VE_UNCLAIMED_REWARDS, amount) + return _validator_rewarded_log(tx_state, val_id, from_addr, amount) + + +def apply_commission_to_auth_account( + tx_state: TransactionState, + val_id: int, + auth: Address, + amount: int, +) -> None: + """ + Credit ``amount`` commission to the auth delegator's rewards (MIP-11 + ``staking_contract.apply_commission_to_auth_account(auth)``). + + Storage-only; the balance transfer is done by the caller. + """ + _add_slot(tx_state, _delegator_base(val_id, auth) + DEL_REWARDS, amount) + + +# 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") + + +def distribute_priority_fees(tx_state: TransactionState) -> None: + """ + Distribute the distribution account's balance (MIP-11 + ``distribution_account.distribute(block_leader)``). + + The spec models this as a method on the fee5 account called by the + system at end of block ("no transaction can call it"); it is a direct + end-of-block call, and the logic lives here because fee5 has no code. + The client emits a ValidatorRewarded event here to its event stream + but never into a receipt, so it has no effect on state or the header + bloom and is dropped. Step numbers follow the spec pseudocode. + """ + # 1. Load balance and clear it for the block. + total_balance = int( + get_account(tx_state, FEE_DISTRIBUTION_ADDRESS).balance + ) + if total_balance == 0: + return + set_account_balance(tx_state, FEE_DISTRIBUTION_ADDRESS, U256(0)) + + # 2. Resolve the proposer's validator id. + # Divergence from the spec: it resolves ``val_id(block_leader)`` from + # ``block.coinbase``; here the cached ``proposer_val_id`` (set by the + # reward syscall and cleared each block) is read instead, so a block + # with no reward syscall burns the balance. Equal for a valid block + # whose reward names the block leader. + val_id = read_proposer_val_id(tx_state) + + # Divergence from the spec: it omits these guards; the validator + # existence and active-set checks are enforced here, both burning the + # balance. + if val_id == 0 or not validator_exists(tx_state, val_id): + return + active_stake = read_consensus_stake(tx_state, val_id) + if active_stake == 0: + return + + # 3-4. Commission on the pool's commission rate. + # Divergence from the spec's ``val_consensus``: the epoch-boundary + # snapshot view is not modeled here (equal off epoch boundaries). + commission_rate = read_consensus_commission(tx_state, val_id) + commission_amount = total_balance * commission_rate // MON + distribute_amount = total_balance - commission_amount + + # 5. Credit commission to the auth delegator (always, before the dust + # check). The drained balance moves into the staking contract. + create_ether(tx_state, STAKING_ADDRESS, U256(commission_amount)) + apply_commission_to_auth_account( + tx_state, + val_id, + read_auth_address(tx_state, val_id), + commission_amount, + ) + if distribute_amount < DUST_THRESHOLD: + return + + # 6. Distribute the remainder to the validator pool. The returned log + # is dropped (see the docstring); only the pool credit matters. + create_ether(tx_state, STAKING_ADDRESS, U256(distribute_amount)) + staking_distribute( + tx_state, + val_id, + FEE_DISTRIBUTION_ADDRESS, + distribute_amount, + active_stake, + ) + + +def _syscall_reward(evm: Evm) -> None: + """ + Handle the reward syscall. + + Mint the reward into the pool, credit commission to the auth + delegator, and credit the remainder to the proposer pool the same way + ``distribute_priority_fees`` does, then set ``proposer_val_id``. The + reward may be zero (proposer set with no block reward); the credits + are then no-ops, but ValidatorRewarded is emitted unconditionally to + match the client. This log lands in the syscall tx's receipt and thus + the block bloom, since the reward syscall is a transaction (unlike the + end-of-block ``distribute_priority_fees``). + """ + tx_state = evm.message.tx_env.state + data = evm.message.data + # calldata: selector(4) + abi address (32, right-aligned 20 bytes) + author = Address(bytes(data[16:36])) + val_id = read_val_id(tx_state, author) + if val_id == 0: + evm.output = b"not in validator set" + raise RevertInMonadPrecompile + active_stake = read_consensus_stake(tx_state, val_id) + if active_stake == 0: + evm.output = b"not in validator set" + raise RevertInMonadPrecompile + + raw_reward = int(evm.message.value) + create_ether(tx_state, STAKING_ADDRESS, U256(raw_reward)) + commission_rate = read_consensus_commission(tx_state, val_id) + commission_amount = raw_reward * commission_rate // MON + apply_commission_to_auth_account( + tx_state, + val_id, + read_auth_address(tx_state, val_id), + commission_amount, + ) + log = staking_distribute( + tx_state, + val_id, + SYSTEM_SENDER, + raw_reward - commission_amount, + active_stake, + ) + evm.logs = evm.logs + (log,) + + write_proposer_val_id(tx_state, val_id) + + +# 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 + selector = bytes(data[:4]) if len(data) >= 4 else b"" + + # Staking syscalls arrive as transactions from the system sender. + if selector == SELECTOR_SYSCALL_REWARD and ( + evm.message.caller == SYSTEM_SENDER + ): + charge_gas(evm, GAS_UNKNOWN_SELECTOR) + _syscall_reward(evm) + return + + # 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_spec_tools/evm_tools/loaders/fork_loader.py b/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py index 9e769dd61c..16ae60912f 100644 --- a/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py +++ b/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py @@ -242,6 +242,26 @@ def has_compute_requests_hash(self) -> bool: return False return hasattr(module, "compute_requests_hash") + @property + def distribute_priority_fees(self) -> Any: + """distribute_priority_fees function of the fork (MIP-11).""" + return self._module("fork").distribute_priority_fees + + @property + def has_distribute_priority_fees(self) -> bool: + """Check if the fork distributes priority fees at end of block.""" + return hasattr(self._module("fork"), "distribute_priority_fees") + + @property + def execute_block_prelude(self) -> Any: + """execute_block_prelude function of the fork (MIP-11).""" + return self._module("fork").execute_block_prelude + + @property + def has_execute_block_prelude(self) -> bool: + """Check if the fork runs a block prelude (clears the proposer).""" + return hasattr(self._module("fork"), "execute_block_prelude") + @property def Bloom(self) -> Any: """Bloom class of the fork.""" diff --git a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py index 9b6539df55..c5e1ba838d 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py @@ -498,6 +498,9 @@ def _run_blockchain_test(self, block_env: Any, block_output: Any) -> None: block_env.state, block_env.number ) + if self.fork.has_execute_block_prelude: + self.fork.execute_block_prelude(block_env) + if self.fork.has_compute_requests_hash: self.fork.process_unchecked_system_transaction( block_env=block_env, @@ -556,6 +559,9 @@ def _run_blockchain_test(self, block_env: Any, block_output: Any) -> None: if self.fork.has_compute_requests_hash: self.fork.process_general_purpose_requests(block_env, block_output) + if self.fork.has_distribute_priority_fees: + self.fork.distribute_priority_fees(block_env) + if self.fork.has_hash_block_access_list: block_output.block_access_list = self.fork.build_block_access_list( block_env.block_access_list_builder, block_env.state diff --git a/tests/monad_ten/mip11_priority_fee_distribution/__init__.py b/tests/monad_ten/mip11_priority_fee_distribution/__init__.py new file mode 100644 index 0000000000..76bae679d6 --- /dev/null +++ b/tests/monad_ten/mip11_priority_fee_distribution/__init__.py @@ -0,0 +1 @@ +"""Tests for MIP-11 priority fee distribution.""" diff --git a/tests/monad_ten/mip11_priority_fee_distribution/conftest.py b/tests/monad_ten/mip11_priority_fee_distribution/conftest.py new file mode 100644 index 0000000000..9aa7e684a4 --- /dev/null +++ b/tests/monad_ten/mip11_priority_fee_distribution/conftest.py @@ -0,0 +1 @@ +"""Pytest configuration for MIP-11 tests.""" diff --git a/tests/monad_ten/mip11_priority_fee_distribution/helpers.py b/tests/monad_ten/mip11_priority_fee_distribution/helpers.py new file mode 100644 index 0000000000..b73794206d --- /dev/null +++ b/tests/monad_ten/mip11_priority_fee_distribution/helpers.py @@ -0,0 +1,50 @@ +"""Transaction helpers for MIP-11 tests.""" + +from execution_testing import Alloc, Transaction + +from .spec import BASE_FEE, MON + +# Gas limit used by fee-bearing transactions. The accumulated priority +# fee is exactly ``gas_limit * priority_fee_per_gas`` (Monad charges the +# full gas limit), so a fee divisible by this value is reproduced +# exactly. +FEE_TX_GAS = 10**6 + + +def make_fee_tx( + pre: Alloc, + fee: int, + *, + gas_limit: int = FEE_TX_GAS, +) -> Transaction: + """ + Return a transaction whose priority fee equals ``fee`` exactly. + + ``fee`` must be divisible by ``gas_limit``. + """ + assert fee % gas_limit == 0, "fee must be divisible by gas_limit" + priority = fee // gas_limit + max_fee = BASE_FEE + priority + gas_cost = gas_limit * max_fee + return Transaction( + gas_limit=gas_limit, + max_fee_per_gas=max_fee, + max_priority_fee_per_gas=priority, + to=pre.fund_eoa(0), + sender=pre.fund_eoa(gas_cost + 11 * MON), + ) + + +def make_fee_txs( + pre: Alloc, + total_fee: int, + n_txs: int, + *, + gas_limit: int = FEE_TX_GAS, +) -> list[Transaction]: + """Return ``n_txs`` transactions whose priority fees sum to ``fee``.""" + assert total_fee % n_txs == 0, "total_fee must be divisible by n_txs" + return [ + make_fee_tx(pre, total_fee // n_txs, gas_limit=gas_limit) + for _ in range(n_txs) + ] diff --git a/tests/monad_ten/mip11_priority_fee_distribution/spec.py b/tests/monad_ten/mip11_priority_fee_distribution/spec.py new file mode 100644 index 0000000000..0247f7436b --- /dev/null +++ b/tests/monad_ten/mip11_priority_fee_distribution/spec.py @@ -0,0 +1,161 @@ +""" +Constants and helpers for MIP-11 priority fee distribution tests. + +State is modeled with the same namespaced storage layout as the staking +precompile, so fixtures match it. + +Spec: https://mips.monad.xyz/MIPs/MIP-11 +""" + +from collections.abc import Sequence +from dataclasses import dataclass, field + +from execution_testing import EOA, Account, Address, Transaction + +STAKING_PRECOMPILE = Address(0x1000) +FEE_DISTRIBUTION = Address(0xFEE5FEE5FEE5FEE5FEE5FEE5FEE5FEE5FEE5FEE5) + +# System sender that signs staking syscall transactions, with a fixed, +# publicly known signing key. +SYSTEM_SENDER = Address(0x6F49A8F621353F12378D0046E7D7E4B9B249DC9E) +SYSTEM_KEY = 0xB0358E6D701A955D9926676F227E40172763296B317FF554E49CDF2C2C35F8A7 + +REWARD_SELECTOR = bytes.fromhex("791bdcf3") + +MON = 10**18 +UNIT_BIAS = 10**36 +DUST_THRESHOLD = 10**9 + +# Block base fee the framework uses for the first block; priority fee per +# gas is derived against it so a gas-paid fee is reproduced exactly. +BASE_FEE = 7 + +# keccak256(b"") — EXTCODEHASH of an existing account with no code. +EMPTY_CODE_HASH = ( + 0xC5D2460186F7233C927E7DB2DCC703C0E500B653CA82273B7BFAD8045D85A470 +) + +# Storage namespaces and offsets (must match the staking precompile). +NS_CONSENSUS_STAKE = 0x04 +NS_VAL_ID_SECP = 0x06 +NS_VAL_EXECUTION = 0x09 +NS_DELEGATOR = 0x0B +KEY_PROPOSER_VAL_ID = 0x04 +VE_ACC_REWARD_PER_TOKEN = 1 +VE_ADDRESS_FLAGS = 6 +VE_UNCLAIMED_REWARDS = 7 +CV_STAKE = 0 +CV_COMMISSION = 1 +DEL_REWARDS = 2 + + +def _base(ns: int, body: bytes) -> int: + """Return the uint256 base key of a namespaced storage record.""" + return int.from_bytes((bytes([ns]) + body).ljust(32, b"\x00"), "big") + + +def val_id_slot(address: Address) -> int: + """Storage slot of the ``val_id`` mapping for a secp address.""" + return _base(NS_VAL_ID_SECP, bytes(address)) + + +def val_exec_slot(val_id: int, offset: int) -> int: + """Storage slot of a ValExecution record field.""" + return _base(NS_VAL_EXECUTION, val_id.to_bytes(8, "big")) + offset + + +def consensus_slot(val_id: int, offset: int) -> int: + """Storage slot of a ConsensusView record field.""" + return _base(NS_CONSENSUS_STAKE, val_id.to_bytes(8, "big")) + offset + + +def delegator_slot(val_id: int, address: Address, offset: int) -> int: + """Storage slot of a Delegator record field.""" + body = val_id.to_bytes(8, "big") + bytes(address) + return _base(NS_DELEGATOR, body) + offset + + +@dataclass +class Validator: + """A staking validator for test setup.""" + + val_id: int + auth: Address + stake: int + commission: int = 0 + + +def staking_storage(validators: Sequence[Validator]) -> dict[int, int]: + """Return the staking-account storage seeding the given validators.""" + storage: dict[int, int] = {} + for v in validators: + storage[val_id_slot(v.auth)] = v.val_id << 192 + storage[val_exec_slot(v.val_id, VE_ADDRESS_FLAGS)] = ( + int.from_bytes(bytes(v.auth), "big") << 96 + ) + storage[consensus_slot(v.val_id, CV_STAKE)] = v.stake + if v.commission: + storage[consensus_slot(v.val_id, CV_COMMISSION)] = v.commission + return storage + + +def staking_account(validators: Sequence[Validator]) -> Account: + """Build the staking account pre-seeded with the given validators.""" + return Account(nonce=1, storage=staking_storage(validators)) + + +def reward_tx( + author: Address, *, nonce: int = 0, value: int = 0 +) -> Transaction: + """ + Return a reward-syscall system transaction naming ``author``. + + Signed by the public system key so its sender recovers to + ``SYSTEM_SENDER``. System transactions declare no gas, so both the + gas limit and price are zero. + """ + return Transaction( + ty=0, + nonce=nonce, + gas_limit=0, + gas_price=0, + to=STAKING_PRECOMPILE, + value=value, + data=REWARD_SELECTOR + bytes(12) + bytes(author), + sender=EOA(key=SYSTEM_KEY), + ) + + +@dataclass +class Distribution: + """Storage/balance deltas of distributing ``fee`` to ``validator``.""" + + staking_balance: int + storage: dict[int, int] = field(default_factory=dict) + + +def distribution(validator: Validator, fee: int) -> Distribution: + """ + Return the staking-account deltas of distributing ``fee``. + + Mirrors ``distribute_priority_fees``: commission to the auth + delegator's pool accumulator (via the reward path in these tests the + auth is the sole recipient of commission), remainder to the pool + accumulator, sub-dust remainder burned. + """ + commission = fee * validator.commission // MON + del_reward = fee - commission + result = Distribution(staking_balance=commission) + if commission > 0: + result.storage[ + delegator_slot(validator.val_id, validator.auth, DEL_REWARDS) + ] = commission + if del_reward >= DUST_THRESHOLD: + result.staking_balance += del_reward + result.storage[ + val_exec_slot(validator.val_id, VE_ACC_REWARD_PER_TOKEN) + ] = del_reward * UNIT_BIAS // validator.stake + result.storage[ + val_exec_slot(validator.val_id, VE_UNCLAIMED_REWARDS) + ] = del_reward + return result diff --git a/tests/monad_ten/mip11_priority_fee_distribution/test_distribution.py b/tests/monad_ten/mip11_priority_fee_distribution/test_distribution.py new file mode 100644 index 0000000000..ffaa587205 --- /dev/null +++ b/tests/monad_ten/mip11_priority_fee_distribution/test_distribution.py @@ -0,0 +1,345 @@ +""" +MIP-11 priority fee distribution to a validator pool. + +A reward-syscall system transaction sets the block proposer; the +end-of-block distribution then credits the proposer pool's accumulator +(commission to the auth delegator), matching the staking precompile's +storage layout and accumulator math. + +Spec: https://mips.monad.xyz/MIPs/MIP-11 +""" + +import pytest +from execution_testing import ( + Account, + Address, + Alloc, + Block, + BlockchainTestFiller, + Bytecode, + Op, + Transaction, +) + +from .helpers import FEE_TX_GAS, make_fee_txs +from .spec import ( + BASE_FEE, + DUST_THRESHOLD, + FEE_DISTRIBUTION, + KEY_PROPOSER_VAL_ID, + MON, + STAKING_PRECOMPILE, + Validator, + distribution, + reward_tx, + staking_storage, +) + +pytestmark = [ + pytest.mark.valid_from("MONAD_NEXT"), + pytest.mark.pre_alloc_mutable, +] + + +def _staking_post_account( + validator: Validator, fee: int, *, proposer: int +) -> Account: + """Return the expected staking account after distributing ``fee``.""" + storage = staking_storage([validator]) + dist = distribution(validator, fee) + storage.update(dist.storage) + if proposer: + storage[KEY_PROPOSER_VAL_ID] = proposer << 192 + return Account(nonce=1, balance=dist.staking_balance, storage=storage) + + +@pytest.mark.parametrize( + "fee", + [ + pytest.param(DUST_THRESHOLD - 1, id="below_dust"), + pytest.param(DUST_THRESHOLD, id="dust_threshold"), + pytest.param(MON, id="one_mon"), + pytest.param(1_000_000 * MON, id="million_mon"), + pytest.param(1_000_001 * MON, id="over_million_mon"), + pytest.param(10**30, id="huge"), + ], +) +def test_fee_amounts( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fee: int, +) -> None: + """ + Distribute accumulated fees of varying size to the proposer pool. + + Sub-dust fees are burned; everything at or above the dust threshold + is distributed (there is no upper cap). The distribution account is + emptied. + """ + validator = Validator(val_id=1, auth=pre.fund_eoa(0), stake=MON) + pre[STAKING_PRECOMPILE] = Account( + nonce=1, storage=staking_storage([validator]) + ) + pre[FEE_DISTRIBUTION] = Account(balance=fee) + + blockchain_test( + pre=pre, + post={ + FEE_DISTRIBUTION: None, + STAKING_PRECOMPILE: _staking_post_account( + validator, fee, proposer=1 + ), + }, + blocks=[Block(txs=[reward_tx(validator.auth)])], + ) + + +def test_commission_credited_to_auth( + blockchain_test: BlockchainTestFiller, + pre: Alloc, +) -> None: + """A nonzero commission is credited to the auth delegator's rewards.""" + fee = 10 * MON + validator = Validator( + val_id=1, auth=pre.fund_eoa(0), stake=MON, commission=10**17 + ) + pre[STAKING_PRECOMPILE] = Account( + nonce=1, storage=staking_storage([validator]) + ) + pre[FEE_DISTRIBUTION] = Account(balance=fee) + + blockchain_test( + pre=pre, + post={ + FEE_DISTRIBUTION: None, + STAKING_PRECOMPILE: _staking_post_account( + validator, fee, proposer=1 + ), + }, + blocks=[Block(txs=[reward_tx(validator.auth)])], + ) + + +def test_no_proposer_burns( + blockchain_test: BlockchainTestFiller, + pre: Alloc, +) -> None: + """ + Without a reward syscall the proposer is cleared, so accumulated fees + are burned and the validator pool is untouched. + """ + fee = 5 * MON + validator = Validator(val_id=1, auth=pre.fund_eoa(0), stake=MON) + seeded = staking_storage([validator]) + pre[STAKING_PRECOMPILE] = Account(nonce=1, storage=seeded) + pre[FEE_DISTRIBUTION] = Account(balance=fee) + + blockchain_test( + pre=pre, + post={ + FEE_DISTRIBUTION: None, + # Prelude clears the proposer; storage otherwise unchanged. + STAKING_PRECOMPILE: Account(nonce=1, balance=0, storage=seeded), + }, + blocks=[Block(txs=[])], + ) + + +@pytest.mark.parametrize("n_txs", [1, 3]) +def test_priority_fee_from_gas( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + n_txs: int, +) -> None: + """ + Priority fees paid by real transactions accrue to the distribution + account and are distributed to the proposer pool. + """ + total_fee = 6 * MON + validator = Validator(val_id=1, auth=pre.fund_eoa(0), stake=MON) + pre[STAKING_PRECOMPILE] = Account( + nonce=1, storage=staking_storage([validator]) + ) + + txs = [reward_tx(validator.auth)] + make_fee_txs(pre, total_fee, n_txs) + + blockchain_test( + pre=pre, + post={ + FEE_DISTRIBUTION: None, + STAKING_PRECOMPILE: _staking_post_account( + validator, total_fee, proposer=1 + ), + }, + blocks=[Block(txs=txs)], + ) + + +@pytest.mark.parametrize( + "halt", + [Op.STOP, pytest.param(Op.REVERT(0, 0), id="revert"), Op.INVALID], +) +def test_reverted_fee_distributed( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + halt: Bytecode, +) -> None: + """ + A transaction's priority fee is distributed regardless of outcome. + + The full gas limit is charged whether the call halts, reverts, or + hits an invalid opcode, so the same fee accrues and the pool receives + the same reward. + """ + fee = 6 * MON + validator = Validator(val_id=1, auth=pre.fund_eoa(0), stake=MON) + pre[STAKING_PRECOMPILE] = Account( + nonce=1, storage=staking_storage([validator]) + ) + + target = pre.deploy_contract(halt) + priority = fee // FEE_TX_GAS + fee_tx = Transaction( + gas_limit=FEE_TX_GAS, + max_fee_per_gas=BASE_FEE + priority, + max_priority_fee_per_gas=priority, + to=target, + sender=pre.fund_eoa(FEE_TX_GAS * (BASE_FEE + priority) + 11 * MON), + ) + + blockchain_test( + pre=pre, + post={ + FEE_DISTRIBUTION: None, + STAKING_PRECOMPILE: _staking_post_account( + validator, fee, proposer=1 + ), + }, + blocks=[Block(txs=[reward_tx(validator.auth), fee_tx])], + ) + + +# MIP-4 reserve balance: an account's ending balance may not drop below +# this except via a qualifying emptying transaction. +RESERVE_BALANCE = 10 * MON + + +@pytest.mark.parametrize("violated", [False, True]) +def test_reserve_violation_fee_distributed( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + violated: bool, +) -> None: + """ + A transaction reverting on a reserve-balance violation still pays its + priority fee, so the pool receives the same reward as on success. + + The sender is delegated, so it never qualifies for the + emptying-transaction exception. It sends value on top of gas: at the + reserve the spend dips below it and the transaction reverts; funded + above the reserve it succeeds. The priority fee accrues either way. + """ + fee = 4 * MON + value = MON + validator = Validator(val_id=1, auth=pre.fund_eoa(0), stake=MON) + pre[STAKING_PRECOMPILE] = Account( + nonce=1, storage=staking_storage([validator]) + ) + + slot = 0x1 + target = pre.deploy_contract(Op.SSTORE(slot, 1)) + priority = fee // FEE_TX_GAS + gas_cost = FEE_TX_GAS * (BASE_FEE + priority) + balance = ( + RESERVE_BALANCE + if violated + else RESERVE_BALANCE + gas_cost + value + MON + ) + fee_tx = Transaction( + gas_limit=FEE_TX_GAS, + max_fee_per_gas=BASE_FEE + priority, + max_priority_fee_per_gas=priority, + to=target, + value=value, + sender=pre.fund_eoa(balance, delegation=Address(0x0111)), + ) + + blockchain_test( + pre=pre, + post={ + FEE_DISTRIBUTION: None, + STAKING_PRECOMPILE: _staking_post_account( + validator, fee, proposer=1 + ), + # The value transfers and marker persist only on success. + target: ( + Account(balance=0, storage={}) + if violated + else Account(balance=value, storage={slot: 1}) + ), + }, + blocks=[Block(txs=[reward_tx(validator.auth), fee_tx])], + ) + + +def test_in_block_transfer_distributed( + blockchain_test: BlockchainTestFiller, + pre: Alloc, +) -> None: + """ + A plain value transfer into fee5 during the block is distributed to + the proposer pool: distribution forwards the account's whole balance, + not only accumulated priority fees. + """ + transfer = 5 * MON + validator = Validator(val_id=1, auth=pre.fund_eoa(0), stake=MON) + pre[STAKING_PRECOMPILE] = Account( + nonce=1, storage=staking_storage([validator]) + ) + + transfer_tx = Transaction( + gas_limit=100_000, + max_fee_per_gas=BASE_FEE, + max_priority_fee_per_gas=0, + to=FEE_DISTRIBUTION, + value=transfer, + sender=pre.fund_eoa(transfer + 11 * MON), + ) + + blockchain_test( + pre=pre, + post={ + FEE_DISTRIBUTION: None, + STAKING_PRECOMPILE: _staking_post_account( + validator, transfer, proposer=1 + ), + }, + blocks=[Block(txs=[reward_tx(validator.auth), transfer_tx])], + ) + + +def test_inactive_validator_burns( + blockchain_test: BlockchainTestFiller, + pre: Alloc, +) -> None: + """ + A validator with no active stake cannot be set as proposer. + + The reward syscall reverts for a zero-stake validator (as if it were + deactivated at an epoch boundary), so the proposer stays cleared and + accumulated fees are burned. + """ + fee = 5 * MON + validator = Validator(val_id=1, auth=pre.fund_eoa(0), stake=0) + seeded = staking_storage([validator]) + pre[STAKING_PRECOMPILE] = Account(nonce=1, storage=seeded) + pre[FEE_DISTRIBUTION] = Account(balance=fee) + + blockchain_test( + pre=pre, + post={ + FEE_DISTRIBUTION: None, + STAKING_PRECOMPILE: Account(nonce=1, balance=0, storage=seeded), + }, + blocks=[Block(txs=[reward_tx(validator.auth)])], + ) diff --git a/tests/monad_ten/mip11_priority_fee_distribution/test_fee5.py b/tests/monad_ten/mip11_priority_fee_distribution/test_fee5.py new file mode 100644 index 0000000000..68d5ea2e3f --- /dev/null +++ b/tests/monad_ten/mip11_priority_fee_distribution/test_fee5.py @@ -0,0 +1,210 @@ +""" +MIP-11 distribution account behaves as an ordinary account. + +The fee5 address is not a precompile: it can be called, sent value, +introspected, selfdestructed to, and used as a 7702 delegation target +like any other account, and it is cold on first access (not pre-warmed +like a precompile or the coinbase). The end-of-block distribution +empties whatever balance it holds (burned here, since no validator is +registered). +""" + +import pytest +from execution_testing import ( + AccessList, + Account, + Alloc, + Block, + BlockchainTestFiller, + CodeGasMeasure, + Op, + Transaction, +) +from execution_testing.forks.helpers import Fork +from execution_testing.test_types.receipt_types import TransactionReceipt + +from .spec import EMPTY_CODE_HASH, FEE_DISTRIBUTION, MON + +pytestmark = [ + pytest.mark.valid_from("MONAD_NEXT"), + pytest.mark.pre_alloc_mutable, +] + +slot_size = 0x1 +slot_hash = 0x2 +slot_balance = 0x3 +slot_copy = 0x4 +slot_success = 0x5 +slot_gas = 0x1 + + +@pytest.mark.parametrize("funded", [False, True]) +def test_fee5_introspection( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + funded: bool, +) -> None: + """EXTCODESIZE/HASH/COPY and BALANCE see fee5 as a plain account.""" + balance = 3 * MON if funded else 0 + if funded: + pre[FEE_DISTRIBUTION] = Account(balance=balance) + + code = ( + Op.SSTORE(slot_size, Op.EXTCODESIZE(FEE_DISTRIBUTION)) + + Op.SSTORE(slot_hash, Op.EXTCODEHASH(FEE_DISTRIBUTION)) + + Op.SSTORE(slot_balance, Op.BALANCE(FEE_DISTRIBUTION)) + + Op.EXTCODECOPY(FEE_DISTRIBUTION, 0, 0, 32) + + Op.SSTORE(slot_copy, Op.MLOAD(0)) + ) + contract = pre.deploy_contract(code) + + tx = Transaction(gas_limit=200_000, to=contract, sender=pre.fund_eoa()) + + blockchain_test( + pre=pre, + post={ + contract: Account( + storage={ + slot_size: 0, + slot_hash: EMPTY_CODE_HASH if funded else 0, + slot_balance: balance, + slot_copy: 0, + } + ), + # Any balance read above is burned at end of block. + FEE_DISTRIBUTION: None, + }, + blocks=[Block(txs=[tx])], + ) + + +def test_fee5_call_with_value( + blockchain_test: BlockchainTestFiller, + pre: Alloc, +) -> None: + """A CALL with value to fee5 succeeds and transfers value.""" + value = 5 * MON + code = Op.SSTORE( + slot_success, Op.CALL(address=FEE_DISTRIBUTION, value=value) + ) + contract = pre.deploy_contract(code, balance=value) + + tx = Transaction(gas_limit=200_000, to=contract, sender=pre.fund_eoa()) + + blockchain_test( + pre=pre, + post={ + contract: Account(balance=0, storage={slot_success: 1}), + FEE_DISTRIBUTION: None, + }, + blocks=[Block(txs=[tx])], + ) + + +def test_fee5_top_level_tx( + blockchain_test: BlockchainTestFiller, + pre: Alloc, +) -> None: + """A top-level transaction to fee5 with value succeeds.""" + tx = Transaction( + gas_limit=100_000, + to=FEE_DISTRIBUTION, + value=5 * MON, + sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt(status=1), + ) + + blockchain_test( + pre=pre, + post={FEE_DISTRIBUTION: None}, + blocks=[Block(txs=[tx])], + ) + + +@pytest.mark.parametrize("value", [0, 5 * MON]) +def test_fee5_selfdestruct_beneficiary( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + value: int, +) -> None: + """A contract may name fee5 as its SELFDESTRUCT beneficiary.""" + contract = pre.deploy_contract( + Op.SELFDESTRUCT(FEE_DISTRIBUTION), balance=value + ) + + tx = Transaction(gas_limit=200_000, to=contract, sender=pre.fund_eoa()) + + blockchain_test( + pre=pre, + post={ + # Predeployed contract: EIP-6780 keeps it, drained to 0. + contract: Account(balance=0), + FEE_DISTRIBUTION: None, + }, + blocks=[Block(txs=[tx])], + ) + + +def test_fee5_as_delegation_target( + blockchain_test: BlockchainTestFiller, + pre: Alloc, +) -> None: + """An EOA delegating to fee5 executes empty code (a no-op).""" + delegator = pre.fund_eoa(0, delegation=FEE_DISTRIBUTION) + code = Op.SSTORE(slot_success, Op.CALL(address=delegator)) + contract = pre.deploy_contract(code) + + tx = Transaction(gas_limit=200_000, to=contract, sender=pre.fund_eoa()) + + blockchain_test( + pre=pre, + post={contract: Account(storage={slot_success: 1})}, + blocks=[Block(txs=[tx])], + ) + + +@pytest.mark.parametrize("access_listed", [False, True]) +def test_fee5_access_warmth( + blockchain_test: BlockchainTestFiller, + pre: Alloc, + fork: Fork, + access_listed: bool, +) -> None: + """ + fee5 is cold by default and warms via an access-list entry. + + A single ``BALANCE(fee5)`` is metered: it costs the cold access price + when fee5 starts cold, or the warm price when pre-warmed by an + access-list entry. + """ + contract = pre.deploy_contract( + CodeGasMeasure( + code=Op.BALANCE(FEE_DISTRIBUTION), + extra_stack_items=1, + sstore_key=slot_gas, + ) + ) + + expected = Op.BALANCE( + FEE_DISTRIBUTION, address_warm=access_listed + ).gas_cost(fork) + + access_list = ( + [AccessList(address=FEE_DISTRIBUTION, storage_keys=[])] + if access_listed + else [] + ) + + tx = Transaction( + ty=1, + gas_limit=200_000, + to=contract, + sender=pre.fund_eoa(), + access_list=access_list, + ) + + blockchain_test( + pre=pre, + post={contract: Account(storage={slot_gas: expected})}, + blocks=[Block(txs=[tx])], + ) diff --git a/tests/monad_ten/mip11_priority_fee_distribution/test_fork_transition.py b/tests/monad_ten/mip11_priority_fee_distribution/test_fork_transition.py new file mode 100644 index 0000000000..d38c7336d2 --- /dev/null +++ b/tests/monad_ten/mip11_priority_fee_distribution/test_fork_transition.py @@ -0,0 +1,80 @@ +""" +MIP-11 priority fee routing across the fork boundary. + +Before the fork a transaction's priority fee is paid to the block +coinbase; at and after the fork it accrues to the distribution account +and is distributed to the proposer pool. The same fee-bearing +transaction routes differently on each side of the boundary. +""" + +import pytest +from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, +) + +from .helpers import make_fee_tx +from .spec import ( + FEE_DISTRIBUTION, + KEY_PROPOSER_VAL_ID, + MON, + STAKING_PRECOMPILE, + Validator, + distribution, + reward_tx, + staking_storage, +) + +pytestmark = [pytest.mark.pre_alloc_mutable] + + +@pytest.mark.valid_at_transition_to("MONAD_NEXT", subsequent_forks=True) +def test_priority_fee_routing_across_fork( + blockchain_test: BlockchainTestFiller, + pre: Alloc, +) -> None: + """ + A fee-bearing transaction pays the coinbase before the fork and the + proposer pool after it. + + The pre-fork block credits the coinbase with the whole priority fee + and leaves fee5 empty; the post-fork block routes the same fee to + fee5 and distributes it to the pool, crediting the coinbase nothing. + """ + fee = 2 * MON + coinbase = pre.nonexistent_account() + validator = Validator(val_id=1, auth=pre.fund_eoa(0), stake=MON) + pre[STAKING_PRECOMPILE] = Account( + nonce=1, storage=staking_storage([validator]) + ) + + pre_fork = Block( + timestamp=14_999, + fee_recipient=coinbase, + txs=[make_fee_tx(pre, fee)], + ) + post_fork = Block( + timestamp=15_000, + fee_recipient=coinbase, + txs=[reward_tx(validator.auth), make_fee_tx(pre, fee)], + ) + + dist = distribution(validator, fee) + storage = staking_storage([validator]) + storage.update(dist.storage) + storage[KEY_PROPOSER_VAL_ID] = validator.val_id << 192 + + blockchain_test( + pre=pre, + post={ + # Only the pre-fork block paid the coinbase. + coinbase: Account(balance=fee), + FEE_DISTRIBUTION: None, + STAKING_PRECOMPILE: Account( + nonce=1, balance=dist.staking_balance, storage=storage + ), + }, + blocks=[pre_fork, post_fork], + ) diff --git a/tests/monad_ten/mip11_priority_fee_distribution/test_multi_block.py b/tests/monad_ten/mip11_priority_fee_distribution/test_multi_block.py new file mode 100644 index 0000000000..494760fabd --- /dev/null +++ b/tests/monad_ten/mip11_priority_fee_distribution/test_multi_block.py @@ -0,0 +1,123 @@ +"""MIP-11 distribution across multiple blocks.""" + +import pytest +from execution_testing import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + Op, + Transaction, +) + +from .helpers import make_fee_tx +from .spec import ( + BASE_FEE, + FEE_DISTRIBUTION, + KEY_PROPOSER_VAL_ID, + MON, + STAKING_PRECOMPILE, + Validator, + distribution, + reward_tx, + staking_storage, +) + +pytestmark = [ + pytest.mark.valid_from("MONAD_NEXT"), + pytest.mark.pre_alloc_mutable, +] + + +def test_two_blocks( + blockchain_test: BlockchainTestFiller, + pre: Alloc, +) -> None: + """ + Distribution runs once per block and the pool accumulator grows. + + Each block carries a reward syscall (setting the proposer) and a + fee-bearing transaction; the distribution account is emptied every + block and the validator pool reflects both blocks' fees. + """ + fee = 4 * MON + validator = Validator(val_id=1, auth=pre.fund_eoa(0), stake=MON) + pre[STAKING_PRECOMPILE] = Account( + nonce=1, storage=staking_storage([validator]) + ) + + blocks = [ + Block( + txs=[ + reward_tx(validator.auth, nonce=n), + make_fee_tx(pre, fee), + ] + ) + for n in range(2) + ] + + # Two equal distributions accumulate linearly into the pool. + dist = distribution(validator, 2 * fee) + storage = staking_storage([validator]) + storage.update(dist.storage) + storage[KEY_PROPOSER_VAL_ID] = validator.val_id << 192 + + blockchain_test( + pre=pre, + post={ + FEE_DISTRIBUTION: None, + STAKING_PRECOMPILE: Account( + nonce=1, balance=dist.staking_balance, storage=storage + ), + }, + blocks=blocks, + ) + + +def _observer_tx(pre: Alloc, reader: object, slot: int) -> Transaction: + """Record ``BALANCE(fee5)`` at ``slot``; adds no fee (zero tip).""" + return Transaction( + gas_limit=100_000, + max_fee_per_gas=BASE_FEE, + max_priority_fee_per_gas=0, + to=reader, + data=slot.to_bytes(32, "big"), + sender=pre.fund_eoa(), + ) + + +def test_fee5_accrues_per_tx_and_resets( + blockchain_test: BlockchainTestFiller, + pre: Alloc, +) -> None: + """ + fee5 grows by each tx's priority fee and opens the next block empty. + + Zero-tip observer transactions read the running balance: after one + fee it holds that fee, after a second it holds their sum, and the + first read of the following block sees zero. + """ + fee1 = 2 * MON + fee2 = 3 * MON + reader = pre.deploy_contract( + Op.SSTORE(Op.CALLDATALOAD(0), Op.BALANCE(FEE_DISTRIBUTION)) + ) + + block1 = Block( + txs=[ + make_fee_tx(pre, fee1), + _observer_tx(pre, reader, 1), + make_fee_tx(pre, fee2), + _observer_tx(pre, reader, 2), + ] + ) + block2 = Block(txs=[_observer_tx(pre, reader, 3)]) + + blockchain_test( + pre=pre, + post={ + reader: Account(storage={1: fee1, 2: fee1 + fee2, 3: 0}), + FEE_DISTRIBUTION: None, + }, + blocks=[block1, block2], + ) From 7a3d20e341d0b9c83c6f435172bd0dd71f07f112 Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:23:20 +0000 Subject: [PATCH 2/4] refactor(mip11): trim staking precompile to MIP-11 scope Drop the unused, fake-stub ABI (getters, setter stubs, non-reward syscalls, selector/gas tables); keep the reward syscall + distribution. In-place deletions only, so re-applying the full precompile is clean. Co-Authored-By: Claude --- .../vm/precompiled_contracts/staking.py | 381 +----------------- 1 file changed, 15 insertions(+), 366 deletions(-) diff --git a/src/ethereum/forks/monad_next/vm/precompiled_contracts/staking.py b/src/ethereum/forks/monad_next/vm/precompiled_contracts/staking.py index 81ffb830c9..6e36d9dbfa 100644 --- a/src/ethereum/forks/monad_next/vm/precompiled_contracts/staking.py +++ b/src/ethereum/forks/monad_next/vm/precompiled_contracts/staking.py @@ -8,9 +8,10 @@ Introduction ------------ -Implementation of the staking precompiled contract. -Getter functions return constant stub values. -Setter functions are stubs that respect interface rules. +Implementation of the staking precompiled contract, scoped to MIP-11: +the reward syscall and the end-of-block priority fee distribution. Other +selectors (getters/setters/other syscalls) are not implemented here and +revert. """ from ethereum_types.bytes import Bytes @@ -30,7 +31,7 @@ ) from ...utils.hexadecimal import hex_to_address from ...vm import Evm -from ...vm.exceptions import InvalidParameter, RevertInMonadPrecompile +from ...vm.exceptions import RevertInMonadPrecompile from ...vm.gas import charge_gas from . import STAKING_ADDRESS @@ -247,51 +248,11 @@ def apply_commission_to_auth_account( _add_slot(tx_state, _delegator_base(val_id, auth) + DEL_REWARDS, amount) -# 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 charged for the reward syscall. 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") def distribute_priority_fees(tx_state: TransactionState) -> None: @@ -410,256 +371,13 @@ def _syscall_reward(evm: Evm) -> None: write_proposer_val_id(tx_state, val_id) -# 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. + Implement the staking precompiled contract (MIP-11 scope). - Calldata must begin with a 4-byte function selector. Unknown selectors - and malformed calldata cause a revert that consumes all provided gas. + Only the reward syscall from the system sender is handled: it sets the + block proposer and distributes the block reward. Every other call + reverts, consuming all provided gas. Parameters ---------- @@ -678,77 +396,8 @@ def staking(evm: Evm) -> None: _syscall_reward(evm) return - # 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 + # Only the reward syscall is supported by this MIP-11-scoped + # precompile; every other call reverts and consumes all gas. + charge_gas(evm, GAS_UNKNOWN_SELECTOR) + evm.output = b"method not supported" + raise RevertInMonadPrecompile From 88120f1370246c081191303300828bd1a945a6e1 Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:45:20 +0000 Subject: [PATCH 3/4] refactor(mip11): trim distribution docstrings, drop inactive-validator test Co-Authored-By: Claude --- src/ethereum/forks/monad_next/fork.py | 5 +--- .../vm/precompiled_contracts/staking.py | 8 +----- .../test_distribution.py | 27 ------------------- 3 files changed, 2 insertions(+), 38 deletions(-) diff --git a/src/ethereum/forks/monad_next/fork.py b/src/ethereum/forks/monad_next/fork.py index 90460ab38b..c807b2ab2b 100644 --- a/src/ethereum/forks/monad_next/fork.py +++ b/src/ethereum/forks/monad_next/fork.py @@ -876,10 +876,7 @@ def distribute_priority_fees(block_env: vm.BlockEnvironment) -> None: Direct end-of-block call into the staking distribution logic. The distribution is not a transaction; it credits the proposer pool and - empties the distribution account. The client emits a ValidatorRewarded - log here to its event stream, but never into a receipt, so it stays - out of the header bloom (``compute_bloom`` runs over receipts only); - the log is therefore not surfaced to the block's logs. + empties the distribution account. """ dist_state = TransactionState(parent=block_env.state) staking.distribute_priority_fees(dist_state) diff --git a/src/ethereum/forks/monad_next/vm/precompiled_contracts/staking.py b/src/ethereum/forks/monad_next/vm/precompiled_contracts/staking.py index 6e36d9dbfa..63e6a79ca9 100644 --- a/src/ethereum/forks/monad_next/vm/precompiled_contracts/staking.py +++ b/src/ethereum/forks/monad_next/vm/precompiled_contracts/staking.py @@ -263,9 +263,6 @@ def distribute_priority_fees(tx_state: TransactionState) -> None: The spec models this as a method on the fee5 account called by the system at end of block ("no transaction can call it"); it is a direct end-of-block call, and the logic lives here because fee5 has no code. - The client emits a ValidatorRewarded event here to its event stream - but never into a receipt, so it has no effect on state or the header - bloom and is dropped. Step numbers follow the spec pseudocode. """ # 1. Load balance and clear it for the block. total_balance = int( @@ -331,10 +328,7 @@ def _syscall_reward(evm: Evm) -> None: delegator, and credit the remainder to the proposer pool the same way ``distribute_priority_fees`` does, then set ``proposer_val_id``. The reward may be zero (proposer set with no block reward); the credits - are then no-ops, but ValidatorRewarded is emitted unconditionally to - match the client. This log lands in the syscall tx's receipt and thus - the block bloom, since the reward syscall is a transaction (unlike the - end-of-block ``distribute_priority_fees``). + are then no-ops, but ValidatorRewarded is emitted unconditionally. """ tx_state = evm.message.tx_env.state data = evm.message.data diff --git a/tests/monad_ten/mip11_priority_fee_distribution/test_distribution.py b/tests/monad_ten/mip11_priority_fee_distribution/test_distribution.py index ffaa587205..f4cf822926 100644 --- a/tests/monad_ten/mip11_priority_fee_distribution/test_distribution.py +++ b/tests/monad_ten/mip11_priority_fee_distribution/test_distribution.py @@ -316,30 +316,3 @@ def test_in_block_transfer_distributed( }, blocks=[Block(txs=[reward_tx(validator.auth), transfer_tx])], ) - - -def test_inactive_validator_burns( - blockchain_test: BlockchainTestFiller, - pre: Alloc, -) -> None: - """ - A validator with no active stake cannot be set as proposer. - - The reward syscall reverts for a zero-stake validator (as if it were - deactivated at an epoch boundary), so the proposer stays cleared and - accumulated fees are burned. - """ - fee = 5 * MON - validator = Validator(val_id=1, auth=pre.fund_eoa(0), stake=0) - seeded = staking_storage([validator]) - pre[STAKING_PRECOMPILE] = Account(nonce=1, storage=seeded) - pre[FEE_DISTRIBUTION] = Account(balance=fee) - - blockchain_test( - pre=pre, - post={ - FEE_DISTRIBUTION: None, - STAKING_PRECOMPILE: Account(nonce=1, balance=0, storage=seeded), - }, - blocks=[Block(txs=[reward_tx(validator.auth)])], - ) From 42d6b92e13ec443bc372e021738ade7c4158c244 Mon Sep 17 00:00:00 2001 From: pdobacz <5735525+pdobacz@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:58:06 +0000 Subject: [PATCH 4/4] test(mip11): parametrize no_proposer_burns over coinbase-is-validator Pin down that distribution keys off the reward-syscall proposer, not the block coinbase: it burns even when the coinbase is the validator. Co-Authored-By: Claude --- .../test_distribution.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/tests/monad_ten/mip11_priority_fee_distribution/test_distribution.py b/tests/monad_ten/mip11_priority_fee_distribution/test_distribution.py index f4cf822926..7d732ad2d4 100644 --- a/tests/monad_ten/mip11_priority_fee_distribution/test_distribution.py +++ b/tests/monad_ten/mip11_priority_fee_distribution/test_distribution.py @@ -120,13 +120,18 @@ def test_commission_credited_to_auth( ) +@pytest.mark.parametrize("coinbase_is_validator", [False, True]) def test_no_proposer_burns( blockchain_test: BlockchainTestFiller, pre: Alloc, + coinbase_is_validator: bool, ) -> None: """ - Without a reward syscall the proposer is cleared, so accumulated fees - are burned and the validator pool is untouched. + Without a reward syscall the proposer stays cleared, so fees burn. + + Distribution keys off the reward-syscall proposer, not the block + coinbase: it burns even when the coinbase is the validator's own + address (which a coinbase-based resolution would have credited). """ fee = 5 * MON validator = Validator(val_id=1, auth=pre.fund_eoa(0), stake=MON) @@ -134,6 +139,10 @@ def test_no_proposer_burns( pre[STAKING_PRECOMPILE] = Account(nonce=1, storage=seeded) pre[FEE_DISTRIBUTION] = Account(balance=fee) + coinbase = ( + validator.auth if coinbase_is_validator else pre.nonexistent_account() + ) + blockchain_test( pre=pre, post={ @@ -141,7 +150,7 @@ def test_no_proposer_burns( # Prelude clears the proposer; storage otherwise unchanged. STAKING_PRECOMPILE: Account(nonce=1, balance=0, storage=seeded), }, - blocks=[Block(txs=[])], + blocks=[Block(fee_recipient=coinbase, txs=[])], )