diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index bdd5c0c62d0..c4ce81d3865 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -1,18 +1,7 @@ -""" -Ethereum Specification. - -.. contents:: Table of Contents - :backlinks: none - :local: - -Introduction ------------- - -Entry point for the Ethereum specification. -""" +"""Ethereum specification entry point for Amsterdam.""" from dataclasses import dataclass -from typing import Final, List, Optional, Tuple, final +from typing import List, Optional, Tuple, final from ethereum_rlp import rlp from ethereum_types.bytes import Bytes @@ -64,6 +53,18 @@ compute_requests_hash, parse_deposit_requests, ) +from .slot_timing import ( + BASE_SLOT_DURATION_MS, + BLOB_BASE_COST, + BLOB_GAS_PER_BLOB, + SLOT_DURATION_SCHEDULE, + SlotDurationSchedule, + calculate_blob_gas_price_for_slot, + get_blob_schedule, + get_slot_duration_ms, + get_transition_durations, + scale_transition_limit, +) from .state_tracker import ( BlockState, TransactionState, @@ -99,9 +100,6 @@ GasCosts, StateGasCosts, allocate_execution_gas, - calculate_blob_gas_price, - calculate_data_fee, - calculate_excess_blob_gas, calculate_total_blob_gas, settle_transaction_gas, ) @@ -109,39 +107,13 @@ BASE_FEE_MAX_CHANGE_DENOMINATOR = Uint(8) ELASTICITY_MULTIPLIER = Uint(2) -SLOT_DURATION_MS = Uint(10000) -""" -Duration of a consensus-layer slot from this fork onward, in milliseconds. - -Blocks arrive every 10 seconds instead of 12. Per-block rates (the base -fee adjustment, the gas limit, the blob schedule) are rescaled by the -exact ratio ``SLOT_DURATION_MS / PREVIOUS_SLOT_DURATION_MS`` so that -their behavior per unit of wall-clock time is unchanged. -""" -PREVIOUS_SLOT_DURATION_MS = Uint(12000) -""" -Duration of a consensus-layer slot before this fork, in milliseconds. -""" EMPTY_OMMER_HASH = keccak256(rlp.encode([])) SYSTEM_ADDRESS = hex_to_address("0xfffffffffffffffffffffffffffffffffffffffe") BEACON_ROOTS_ADDRESS = hex_to_address( "0x000F3df6D732807Ef1319fB7B8bB8522d0Beac02" ) -""" -Address of the beacon roots ring buffer contract. Its 8191-entry buffer -holds one root per slot, so its wall-clock coverage shrinks from ~27.3 -hours to ~22.8 hours under 10-second slots. The buffer length is part of -the deployed contract and is deliberately not changed by this fork. -""" SYSTEM_TRANSACTION_GAS = Uint(30000000) SYSTEM_MAX_SSTORES_PER_CALL = Uint(16) -""" -Upper bound on the number of new storage slots a single system call is -expected to write. -""" -MAX_BLOB_GAS_PER_BLOCK: Final[U64] = ( - GasCosts.BLOB_SCHEDULE_MAX * GasCosts.PER_BLOB -) VERSIONED_HASH_VERSION_KZG = b"\x01" GWEI_TO_WEI = U256(10**9) @@ -160,12 +132,7 @@ HISTORY_STORAGE_ADDRESS = hex_to_address( "0x0000F90827F1C53a10cb7A02335B175320002935" ) -""" -Address of the block hash history contract. Its 8191-entry window is -denominated in blocks, so its wall-clock coverage shrinks from ~27.3 -hours to ~22.8 hours under 10-second slots. The window length is part of -the deployed contract and is deliberately not changed by this fork. -""" + MAX_BLOCK_SIZE = 10_485_760 SAFETY_MARGIN = 2_097_152 MAX_RLP_BLOCK_SIZE = MAX_BLOCK_SIZE - SAFETY_MARGIN @@ -176,26 +143,17 @@ @slotted_freezable @dataclass class ChainContext: - """ - Chain context needed for block execution. - """ + """Chain context needed for block execution.""" chain_id: U64 - """Identify the chain for transaction signature recovery.""" - block_hashes: List[Hash32] - """Recent ancestor hashes (up to 256) for the ``BLOCKHASH`` opcode.""" - parent_header: Header | PreviousHeader - """Parent header used for header validation and system contracts.""" @final @dataclass class BlockChain: - """ - History and current state of the block chain. - """ + """History and current state of the block chain.""" blocks: List[Block] state: State @@ -203,105 +161,35 @@ class BlockChain: def apply_fork(old: BlockChain) -> BlockChain: - """ - Transforms the state from the previous hard fork (`old`) into the block - chain object for this hard fork and returns it. - - When forks need to implement an irregular state transition, this function - is used to handle the irregularity. See the :ref:`DAO Fork ` for - an example. - - Parameters - ---------- - old : - Previous block chain object. - - Returns - ------- - new : `BlockChain` - Upgraded block chain object for this hard fork. - - """ + """Return the chain after the Amsterdam fork transition.""" return old def get_last_256_block_hashes(chain: BlockChain) -> List[Hash32]: - """ - Obtain the list of hashes of the previous 256 blocks in order of - increasing block number. - - This function will return less hashes for the first 256 blocks. - - The ``BLOCKHASH`` opcode needs to access the latest hashes on the chain, - therefore this function retrieves them. - - Parameters - ---------- - chain : - History and current state. - - Returns - ------- - recent_block_hashes : `List[Hash32]` - Hashes of the recent 256 blocks in order of increasing block number. - - """ + """Return hashes of the most recent 256 blocks in increasing order.""" recent_blocks = chain.blocks[-255:] - # TODO: This function has not been tested rigorously if len(recent_blocks) == 0: return [] recent_block_hashes = [] - for block in recent_blocks: - prev_block_hash = block.header.parent_hash - recent_block_hashes.append(prev_block_hash) - - # We are computing the hash only for the most recent block and not for - # the rest of the blocks as they have successors which have the hash of - # the current block as parent hash. - most_recent_block_hash = keccak256(rlp.encode(recent_blocks[-1].header)) - recent_block_hashes.append(most_recent_block_hash) + recent_block_hashes.append(block.header.parent_hash) + recent_block_hashes.append(keccak256(rlp.encode(recent_blocks[-1].header))) return recent_block_hashes def state_transition(chain: BlockChain, block: Block) -> None: - """ - Attempts to apply a block to an existing block chain. - - All parts of the block's contents need to be verified before being added - to the chain. Blocks are verified by ensuring that the contents of the - block make logical sense with the contents of the parent block. The - information in the block's header must also match the corresponding - information in the block. - - To implement Ethereum, in theory clients are only required to store the - most recent 255 blocks of the chain since as far as execution is - concerned, only those blocks are accessed. Practically, however, clients - should store more blocks to handle reorgs. - - Parameters - ---------- - chain : - History and current state. - block : - Block to apply to `chain`. - - """ + """Apply a block to the chain after validating and executing it.""" chain_context = ChainContext( chain_id=chain.chain_id, block_hashes=get_last_256_block_hashes(chain), parent_header=chain.blocks[-1].header, ) - block_diff = execute_block(block, chain.state, chain_context) - apply_changes_to_state(chain.state, block_diff) chain.blocks.append(block) if len(chain.blocks) > 255: - # Real clients have to store more blocks to deal with reorgs, but the - # protocol only requires the last 255 chain.blocks = chain.blocks[-255:] @@ -310,26 +198,7 @@ def execute_block( pre_state: State, chain_context: ChainContext, ) -> BlockDiff: - """ - Execute a block and validate the resulting roots against the header. - - This method is idempotent. - - Parameters - ---------- - block : - Block to validate and execute. - pre_state : - Pre-execution state provider. - chain_context : - Chain context that the block may need during execution. - - Returns - ------- - block_diff : `BlockDiff` - Account, storage, and code changes produced by block execution. - - """ + """Execute a block and validate the resulting roots against the header.""" if len(rlp.encode(block)) > MAX_RLP_BLOCK_SIZE: raise InvalidBlock("Block rlp size exceeds MAX_RLP_BLOCK_SIZE") @@ -340,7 +209,6 @@ def execute_block( raise InvalidBlock block_state = BlockState(pre_state=pre_state) - block_env = vm.BlockEnvironment( chain_id=chain_context.chain_id, state=block_state, @@ -399,47 +267,97 @@ def execute_block( return block_diff +def get_max_blob_gas_per_block( + slot_number: U64, + slot_duration_schedule: SlotDurationSchedule = SLOT_DURATION_SCHEDULE, +) -> U64: + """ + Return the current per-block blob capacity from the duration schedule. + """ + return ( + BLOB_GAS_PER_BLOB + * get_blob_schedule(slot_number, slot_duration_schedule).maximum + ) + + +def calculate_excess_blob_gas_for_slot( + parent_header: Header | PreviousHeader, + current_slot_number: U64, + slot_duration_schedule: SlotDurationSchedule = SLOT_DURATION_SCHEDULE, +) -> U64: + """Calculate excess blob gas with parameters for the current slot era.""" + excess_blob_gas = U64(0) + blob_gas_used = U64(0) + base_fee_per_gas = Uint(0) + + if isinstance(parent_header, Header): + excess_blob_gas = parent_header.excess_blob_gas + blob_gas_used = parent_header.blob_gas_used + base_fee_per_gas = parent_header.base_fee_per_gas + + blob_schedule = get_blob_schedule( + current_slot_number, slot_duration_schedule + ) + target_blob_gas_per_block = BLOB_GAS_PER_BLOB * blob_schedule.target + parent_blob_gas = excess_blob_gas + blob_gas_used + if parent_blob_gas < target_blob_gas_per_block: + return U64(0) + + target_blob_gas_price = Uint(BLOB_GAS_PER_BLOB) + target_blob_gas_price *= calculate_blob_gas_price_for_slot( + excess_blob_gas, + current_slot_number, + slot_duration_schedule, + ) + + base_blob_tx_price = BLOB_BASE_COST * base_fee_per_gas + if base_blob_tx_price > target_blob_gas_price: + blob_schedule_delta = blob_schedule.maximum - blob_schedule.target + return U64( + excess_blob_gas + + blob_gas_used * blob_schedule_delta // blob_schedule.maximum + ) + + return U64(parent_blob_gas - target_blob_gas_per_block) + + +def calculate_data_fee_for_slot( + excess_blob_gas: U64, + tx: Transaction, + slot_number: U64, + slot_duration_schedule: SlotDurationSchedule = SLOT_DURATION_SCHEDULE, +) -> Uint: + """Calculate the blob data fee using the current slot-duration era.""" + return Uint(calculate_total_blob_gas(tx)) * ( + calculate_blob_gas_price_for_slot( + excess_blob_gas, + slot_number, + slot_duration_schedule, + ) + ) + + def calculate_base_fee_per_gas( block_gas_limit: Uint, parent_gas_limit: Uint, parent_gas_used: Uint, parent_base_fee_per_gas: Uint, gas_limit_reference: Optional[Uint] = None, + slot_duration_ms: Optional[Uint] = None, ) -> Uint: """ - Calculates the base fee per gas for the block. - - The maximum per-block change is scaled by the slot-duration ratio - ``SLOT_DURATION_MS / PREVIOUS_SLOT_DURATION_MS`` so that the fee - adjusts at the same rate per unit of wall-clock time as it did with - 12-second slots. - - Parameters - ---------- - block_gas_limit : - Gas limit of the block for which the base fee is being calculated. - parent_gas_limit : - Gas limit of the parent block, used to derive the gas target the - parent block was built against. - parent_gas_used : - Gas used in the parent block. - parent_base_fee_per_gas : - Base fee per gas of the parent block. - gas_limit_reference : - Reference value the block's gas limit is validated against. This - equals ``parent_gas_limit`` (the default) except for the first - block of the fork, where the parent's gas limit is rescaled by - the slot-duration ratio to preserve gas throughput per unit of - wall-clock time. - - Returns - ------- - base_fee_per_gas : `Uint` - Base fee per gas for the block. + Calculate base fee while preserving responsiveness per wall-clock time. + ``gas_limit_reference`` is transition-scoped and may use the current/parent + duration ratio. ``slot_duration_ms`` is era-scoped and is always compared + with the pre-schedule base duration, so a later 10 -> 8 transition keeps + using 8/12 on every 8-second block rather than reverting to 8/8. """ if gas_limit_reference is None: gas_limit_reference = parent_gas_limit + if slot_duration_ms is None: + slot_duration_ms = get_slot_duration_ms(U64(0)) + parent_gas_target = parent_gas_limit // ELASTICITY_MULTIPLIER if not check_gas_limit(block_gas_limit, gas_limit_reference): raise InvalidBlock @@ -448,37 +366,26 @@ def calculate_base_fee_per_gas( expected_base_fee_per_gas = parent_base_fee_per_gas elif parent_gas_used > parent_gas_target: gas_used_delta = parent_gas_used - parent_gas_target - parent_fee_gas_delta = parent_base_fee_per_gas * gas_used_delta target_fee_gas_delta = parent_fee_gas_delta // parent_gas_target - - # The per-block adjustment is scaled by the slot-duration ratio - # so that the base fee reacts to congestion at the same rate per - # unit of wall-clock time as it did with 12-second slots. The - # division is deferred so the effective denominator is the exact - # rational 48/5 rather than a rounded integer. base_fee_per_gas_delta = max( target_fee_gas_delta - * SLOT_DURATION_MS - // (PREVIOUS_SLOT_DURATION_MS * BASE_FEE_MAX_CHANGE_DENOMINATOR), + * slot_duration_ms + // (BASE_SLOT_DURATION_MS * BASE_FEE_MAX_CHANGE_DENOMINATOR), Uint(1), ) - expected_base_fee_per_gas = ( parent_base_fee_per_gas + base_fee_per_gas_delta ) else: gas_used_delta = parent_gas_target - parent_gas_used - parent_fee_gas_delta = parent_base_fee_per_gas * gas_used_delta target_fee_gas_delta = parent_fee_gas_delta // parent_gas_target - base_fee_per_gas_delta = ( target_fee_gas_delta - * SLOT_DURATION_MS - // (PREVIOUS_SLOT_DURATION_MS * BASE_FEE_MAX_CHANGE_DENOMINATOR) + * slot_duration_ms + // (BASE_SLOT_DURATION_MS * BASE_FEE_MAX_CHANGE_DENOMINATOR) ) - expected_base_fee_per_gas = ( parent_base_fee_per_gas - base_fee_per_gas_delta ) @@ -487,49 +394,43 @@ def calculate_base_fee_per_gas( def validate_header( - parent_header: Header | PreviousHeader, header: Header + parent_header: Header | PreviousHeader, + header: Header, + slot_duration_schedule: SlotDurationSchedule = SLOT_DURATION_SCHEDULE, ) -> None: - """ - Verify a block header against its parent. - - In order to consider a block's header valid, the logic for the - quantities in the header should match the logic for the block itself. - For example the header timestamp should be greater than the block's parent - timestamp because the block was created *after* the parent block. - Additionally, the block's number should be directly following the parent - block's number since it is the next block in the sequence. - - Parameters - ---------- - parent_header : - Header of the parent block. - header : - Header to check for correctness. - - """ + """Verify a block header against its parent and duration era.""" if header.number < Uint(1): raise InvalidBlock - excess_blob_gas = calculate_excess_blob_gas(parent_header) + excess_blob_gas = calculate_excess_blob_gas_for_slot( + parent_header, + header.slot_number, + slot_duration_schedule, + ) if header.excess_blob_gas != excess_blob_gas: raise InvalidBlock if header.gas_used > header.gas_limit: raise InvalidBlock + parent_slot_number: Optional[U64] if isinstance(parent_header, Header): - gas_limit_reference = parent_header.gas_limit + parent_slot_number = parent_header.slot_number else: - # First block of the fork: the gas limit is validated against the - # parent's gas limit rescaled by the slot-duration ratio, so that - # gas throughput per unit of wall-clock time is preserved across - # the transition to 10-second slots. The regular +-1/1024 - # adjustment band applies around the rescaled value. - gas_limit_reference = ( - parent_header.gas_limit - * SLOT_DURATION_MS - // PREVIOUS_SLOT_DURATION_MS - ) + # The legacy parent predates SLOTNUM. This is only an adapter for the + # missing slot value; it is not the transition condition. + parent_slot_number = None + + old_duration_ms, new_duration_ms = get_transition_durations( + parent_slot_number, + header.slot_number, + slot_duration_schedule, + ) + gas_limit_reference = scale_transition_limit( + parent_header.gas_limit, + old_duration_ms, + new_duration_ms, + ) expected_base_fee_per_gas = calculate_base_fee_per_gas( header.gas_limit, @@ -537,6 +438,7 @@ def validate_header( parent_header.gas_used, parent_header.base_fee_per_gas, gas_limit_reference=gas_limit_reference, + slot_duration_ms=new_duration_ms, ) if expected_base_fee_per_gas != header.base_fee_per_gas: raise InvalidBlock @@ -565,78 +467,20 @@ def check_transaction( sender: Address, tx_state: TransactionState, ) -> Tuple[Uint, Tuple[VersionedHash, ...], U64]: - """ - Check if the transaction is includable in the block. - - Parameters - ---------- - block_env : - The block scoped environment. - block_output : - The block output for the current block. - tx : - The transaction. - sender : - The recovered sender address of the transaction. - tx_state : - The transaction state tracker. - - Returns - ------- - effective_gas_price : - The price to charge for gas when the transaction is executed. - blob_versioned_hashes : - The blob versioned hashes of the transaction. - tx_blob_gas_used: - The blob gas used by the transaction. - - Raises - ------ - InvalidBlock : - If the transaction is not includable. - GasUsedExceedsLimitError : - If the gas used by the transaction exceeds the block's gas limit. - NonceMismatchError : - If the nonce of the transaction is not equal to the sender's nonce. - InsufficientBalanceError : - If the sender's balance is not enough to pay for the transaction. - InvalidSenderError : - If the transaction is from an address that does not exist anymore. - PriorityFeeGreaterThanMaxFeeError : - If the priority fee is greater than the maximum fee per gas. - InsufficientMaxFeePerGasError : - If the maximum fee per gas is insufficient for the transaction. - InsufficientMaxFeePerBlobGasError : - If the maximum fee per blob gas is insufficient for the transaction. - BlobGasLimitExceededError : - If the blob gas used by the transaction exceeds the block's blob gas - limit. - InvalidBlobVersionedHashError : - If the transaction contains a blob versioned hash with an invalid - version. - NoBlobDataError : - If the transaction is a type 3 but has no blobs. - BlobCountExceededError : - If the transaction is a type 3 and has more blobs than the limit. - TransactionTypeContractCreationError: - If the transaction type is not allowed to create contracts. - EmptyAuthorizationListError : - If the transaction is a SetCodeTransaction and the authorization list - is empty. - - """ + """Check whether a transaction is includable in the current block.""" regular_gas_available = ( block_env.block_gas_limit - block_output.block_gas_used ) state_gas_available = ( block_env.block_gas_limit - block_output.block_state_gas_used ) - blob_gas_available = MAX_BLOB_GAS_PER_BLOCK - block_output.blob_gas_used + blob_gas_available = ( + get_max_blob_gas_per_block(block_env.slot_number) + - block_output.blob_gas_used + ) - # EIP-8037 per-dimension inclusion check. if min(TX_MAX_GAS_LIMIT, tx.gas) > regular_gas_available: raise GasUsedExceedsLimitError("regular gas used exceeds limit") - if tx.gas > state_gas_available: raise GasUsedExceedsLimitError("state gas used exceeds limit") @@ -682,7 +526,10 @@ def check_transaction( "invalid blob versioned hash" ) - blob_gas_price = calculate_blob_gas_price(block_env.excess_blob_gas) + blob_gas_price = calculate_blob_gas_price_for_slot( + block_env.excess_blob_gas, + block_env.slot_number, + ) if Uint(tx.max_fee_per_blob_gas) < blob_gas_price: raise InsufficientMaxFeePerBlobGasError( "insufficient max fee per blob gas" @@ -729,34 +576,13 @@ def make_receipt( cumulative_gas_used: Uint, logs: Tuple[Log, ...], ) -> Bytes | Receipt: - """ - Make the receipt for a transaction that was executed. - - Parameters - ---------- - tx : - The executed transaction. - error : - Error in the top level frame of the transaction, if any. - cumulative_gas_used : - The total gas used so far in the block after the transaction was - executed. This is the gas used after refunds. - logs : - The logs produced by the transaction. - - Returns - ------- - receipt : - The receipt for the transaction. - - """ + """Make a receipt for an executed transaction.""" receipt = Receipt( succeeded=error is None, cumulative_gas_used=cumulative_gas_used, bloom=logs_bloom(logs), logs=logs, ) - return encode_receipt(tx, receipt) @@ -765,33 +591,7 @@ def process_checked_system_transaction( target_address: Address, data: Bytes, ) -> MessageCallOutput: - """ - Process a system transaction and raise an error if the contract does not - contain code or if the transaction fails. - - Parameters - ---------- - block_env : - The block scoped environment. - target_address : - Address of the contract to call. - data : - Data to pass to the contract. - - Returns - ------- - system_tx_output : `MessageCallOutput` - Output of processing the system transaction. - - """ - # Pre-check that the system contract has code. We use a throwaway - # TransactionState here that is *never* propagated back to BlockState - # (no incorporate_tx_into_block call); the same get_account / get_code - # lookups are performed and properly tracked by - # process_unchecked_system_transaction below, which this function - # always calls. Reading via a TransactionState (rather than directly - # against pre_state) lets us see system contracts deployed earlier in - # the same block — see EIP-7002 and EIP-7251 for this edge case. + """Process a system transaction and require executable target code.""" untracked_state = TransactionState(parent=block_env.state) system_contract_code = get_code( untracked_state, @@ -800,8 +600,8 @@ def process_checked_system_transaction( if len(system_contract_code) == 0: raise InvalidBlock( - f"System contract address {target_address.hex()} does not " - "contain code" + f"System contract address {target_address.hex()} " + "does not contain code" ) system_tx_output = process_unchecked_system_transaction( @@ -809,13 +609,11 @@ def process_checked_system_transaction( target_address, data, ) - if system_tx_output.error: raise InvalidBlock( f"System contract ({target_address.hex()}) call failed: " f"{system_tx_output.error}" ) - return system_tx_output @@ -824,25 +622,7 @@ def process_unchecked_system_transaction( target_address: Address, data: Bytes, ) -> MessageCallOutput: - """ - Process a system transaction without checking if the contract contains - code or if the transaction fails. - - Parameters - ---------- - block_env : - The block scoped environment. - target_address : - Address of the contract to call. - data : - Data to pass to the contract. - - Returns - ------- - system_tx_output : `MessageCallOutput` - Output of processing the system transaction. - - """ + """Process a system transaction without pre- or post-execution checks.""" system_tx_state = TransactionState(parent=block_env.state) system_contract_code = get_code( system_tx_state, @@ -891,11 +671,9 @@ def process_unchecked_system_transaction( ) system_tx_output = process_message_call(system_tx_message) - incorporate_tx_into_block( system_tx_state, block_env.block_access_list_builder ) - return system_tx_output @@ -904,31 +682,7 @@ def apply_body( transactions: Tuple[LegacyTransaction | Bytes, ...], withdrawals: Tuple[Withdrawal, ...], ) -> vm.BlockOutput: - """ - Executes a block. - - Many of the contents of a block are stored in data structures called - tries. There is a transactions trie which is similar to a ledger of the - transactions stored in the current block. There is also a receipts trie - which stores the results of executing a transaction, like the post state - and gas used. This function creates and executes the block that is to be - added to the chain. - - Parameters - ---------- - block_env : - The block scoped environment. - transactions : - Transactions included in the block. - withdrawals : - Withdrawals to be processed in the current block. - - Returns - ------- - block_output : - The block output for the current block. - - """ + """Execute a block body.""" block_output = vm.BlockOutput() process_unchecked_system_transaction( @@ -936,23 +690,19 @@ def apply_body( target_address=BEACON_ROOTS_ADDRESS, data=block_env.parent_beacon_block_root, ) - process_unchecked_system_transaction( block_env=block_env, target_address=HISTORY_STORAGE_ADDRESS, - data=block_env.block_hashes[-1], # The parent hash + data=block_env.block_hashes[-1], ) for i, tx in enumerate(map(decode_transaction, transactions)): process_transaction(block_env, block_output, tx, Uint(i)) - # EIP-7928: Post-execution operations use index N+1 block_env.block_access_list_builder.block_access_index = BlockAccessIndex( ulen(transactions) + Uint(1) ) - process_withdrawals(block_env, block_output, withdrawals) - process_general_purpose_requests( block_env=block_env, block_output=block_output, @@ -961,13 +711,10 @@ def apply_body( block_output.block_access_list = build_block_access_list( block_env.block_access_list_builder, block_env.state ) - - # Validate block access list gas limit constraint (EIP-7928) validate_block_access_list_gas_limit( block_access_list=block_output.block_access_list, block_gas_limit=block_env.block_gas_limit, ) - return block_output @@ -975,18 +722,7 @@ def process_general_purpose_requests( block_env: vm.BlockEnvironment, block_output: vm.BlockOutput, ) -> None: - """ - Process all the requests in the block. - - Parameters - ---------- - block_env : - The execution environment for the Block. - block_output : - The block output for the current block. - - """ - # Requests are to be in ascending order of request type + """Process general-purpose execution requests in ascending type order.""" deposit_requests = parse_deposit_requests(block_output) requests_from_execution = block_output.requests if len(deposit_requests) > 0: @@ -997,7 +733,6 @@ def process_general_purpose_requests( target_address=WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, data=b"", ) - if len(system_withdrawal_tx_output.return_data) > 0: requests_from_execution.append( WITHDRAWAL_REQUEST_TYPE + system_withdrawal_tx_output.return_data @@ -1008,7 +743,6 @@ def process_general_purpose_requests( target_address=CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS, data=b"", ) - if len(system_consolidation_tx_output.return_data) > 0: requests_from_execution.append( CONSOLIDATION_REQUEST_TYPE @@ -1020,7 +754,6 @@ def process_general_purpose_requests( target_address=BUILDER_DEPOSIT_CONTRACT_ADDRESS, data=b"", ) - if len(system_builder_deposit_tx_output.return_data) > 0: requests_from_execution.append( BUILDER_DEPOSIT_REQUEST_TYPE @@ -1032,7 +765,6 @@ def process_general_purpose_requests( target_address=BUILDER_EXIT_CONTRACT_ADDRESS, data=b"", ) - if len(system_builder_exit_tx_output.return_data) > 0: requests_from_execution.append( BUILDER_EXIT_REQUEST_TYPE @@ -1046,30 +778,7 @@ def process_transaction( tx: Transaction, index: Uint, ) -> None: - """ - Execute a transaction against the provided environment. - - This function processes the actions needed to execute a transaction. - It decrements the sender's account balance after calculating the gas fee - and refunds them the proper amount after execution. Calling contracts, - deploying code, and incrementing nonces are all examples of actions that - happen within this function or from a call made within this function. - - Accounts that are marked for deletion are processed and destroyed after - execution. - - Parameters - ---------- - block_env : - Environment for the Ethereum Virtual Machine. - block_output : - The block output for the current block. - tx : - Transaction to execute. - index: - Index of the transaction in the block. - - """ + """Execute a transaction against the provided block environment.""" block_env.block_access_list_builder.block_access_index = BlockAccessIndex( index + Uint(1) ) @@ -1090,7 +799,6 @@ def process_transaction( sender = recover_sender(tx) intrinsic = validate_transaction(tx, sender) - ( effective_gas_price, blob_versioned_hashes, @@ -1104,28 +812,26 @@ def process_transaction( ) sender_account = get_account(tx_state, sender) - if isinstance(tx, BlobTransaction): - blob_gas_fee = calculate_data_fee(block_env.excess_blob_gas, tx) + blob_gas_fee = calculate_data_fee_for_slot( + block_env.excess_blob_gas, + tx, + block_env.slot_number, + ) else: blob_gas_fee = Uint(0) effective_gas_fee = tx.gas * effective_gas_price - - # Split execution gas into a regular grant (capped by the remaining - # regular-gas budget) and a state gas reservoir. allocation = allocate_execution_gas(tx.gas, intrinsic) increment_nonce(tx_state, sender) - sender_balance_after_gas_fee = ( Uint(sender_account.balance) - effective_gas_fee - blob_gas_fee ) set_account_balance(tx_state, sender, U256(sender_balance_after_gas_fee)) - access_list_addresses = set() + access_list_addresses = {block_env.coinbase} access_list_storage_keys = set() - access_list_addresses.add(block_env.coinbase) if has_access_list(tx): for access in tx.access_list: access_list_addresses.add(access.account) @@ -1153,9 +859,7 @@ def process_transaction( ) message = prepare_message(block_env, tx_env, tx) - tx_output = process_message_call(message) - settlement = settle_transaction_gas( tx.gas, intrinsic, @@ -1166,35 +870,27 @@ def process_transaction( ) gas_refund_amount = settlement.gas_left * effective_gas_price - - # For non-1559 transactions effective_gas_price == tx.gas_price priority_fee_per_gas = effective_gas_price - block_env.base_fee_per_gas transaction_fee = settlement.gas_used * priority_fee_per_gas - # refund gas create_ether(tx_state, sender, U256(gas_refund_amount)) - - # transfer miner fees create_ether(tx_state, block_env.coinbase, U256(transaction_fee)) block_output.block_gas_used += settlement.regular_gas_used block_output.block_state_gas_used += settlement.state_gas_used block_output.blob_gas_used += tx_blob_gas_used - block_output.cumulative_gas_used += settlement.gas_used + receipt = make_receipt( tx, tx_output.error, block_output.cumulative_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 for address in tx_output.accounts_to_delete: @@ -1208,52 +904,20 @@ def process_withdrawals( block_output: vm.BlockOutput, withdrawals: Tuple[Withdrawal, ...], ) -> None: - """ - Increase the balance of the withdrawing account. - """ + """Increase balances for withdrawals and record their trie entries.""" wd_state = TransactionState(parent=block_env.state) - for i, wd in enumerate(withdrawals): trie_set( block_output.withdrawals_trie, rlp.encode(Uint(i)), rlp.encode(wd), ) - create_ether(wd_state, wd.address, wd.amount * GWEI_TO_WEI) - incorporate_tx_into_block(wd_state, block_env.block_access_list_builder) def check_gas_limit(gas_limit: Uint, parent_gas_limit: Uint) -> bool: - """ - Validates the gas limit for a block. - - The bounds of the gas limit, ``max_adjustment_delta``, is set as the - quotient of the parent block's gas limit and the - ``LIMIT_ADJUSTMENT_FACTOR``. Therefore, if the gas limit that is passed - through as a parameter is greater than or equal to the *sum* of the - parent's gas and the adjustment delta then the limit for gas is too high - and fails this function's check. Similarly, if the limit is less than or - equal to the *difference* of the parent's gas and the adjustment delta *or* - the predefined ``LIMIT_MINIMUM`` then this function's check fails because - the gas limit doesn't allow for a sufficient or reasonable amount of gas to - be used on a block. - - Parameters - ---------- - gas_limit : - Gas limit to validate. - - parent_gas_limit : - Gas limit of the parent block. - - Returns - ------- - check : `bool` - True if gas limit constraints are satisfied, False otherwise. - - """ + """Validate a block gas limit against its reference value.""" max_adjustment_delta = parent_gas_limit // GasCosts.LIMIT_ADJUSTMENT_FACTOR if gas_limit >= parent_gas_limit + max_adjustment_delta: return False @@ -1261,5 +925,4 @@ def check_gas_limit(gas_limit: Uint, parent_gas_limit: Uint) -> bool: return False if gas_limit < GasCosts.LIMIT_MINIMUM: return False - return True diff --git a/src/ethereum/forks/amsterdam/slot_timing.py b/src/ethereum/forks/amsterdam/slot_timing.py new file mode 100644 index 00000000000..5b6620af0b6 --- /dev/null +++ b/src/ethereum/forks/amsterdam/slot_timing.py @@ -0,0 +1,212 @@ +""" +Slot-duration schedule helpers for EIP-8198. + +The execution layer needs two distinct duration ratios: + +* transition ratios compare the current execution payload's duration with + its parent execution payload's duration and apply once at an era boundary; +* wall-clock response ratios compare the current duration with the + pre-schedule base duration and apply to every block in the era. + +Keeping these operations separate prevents a second slot-duration change +from accidentally reusing the one-off transition ratio as an ongoing rate. +""" + +from dataclasses import dataclass +from typing import Final, Optional, Tuple, final + +from ethereum_types.numeric import U64, Uint + +from ethereum.utils.numeric import taylor_exponential + +BASE_SLOT_DURATION_MS: Final[Uint] = Uint(12000) +"""Pre-schedule slot duration, in milliseconds.""" + +SLOTS_PER_EPOCH: Final[U64] = U64(32) +"""Number of slots per epoch; EIP-8198 does not change this value.""" + +BLOB_GAS_PER_BLOB: Final[U64] = U64(2**17) +BLOB_BASE_COST: Final[Uint] = Uint(2**13) +BLOB_MIN_GASPRICE: Final[Uint] = Uint(1) + + +@final +@dataclass(frozen=True) +class SlotDurationEntry: + """One slot-duration schedule entry.""" + + epoch: U64 + duration_ms: Uint + + +SlotDurationSchedule = Tuple[SlotDurationEntry, ...] + + +# The epoch-zero entry is an always-active initial Amsterdam duration when this +# fork package executes. Additional entries use consensus epoch numbers derived +# from Header.slot_number. A future duration change is therefore schedule data; +# protocol logic does not change. +SLOT_DURATION_SCHEDULE: Final[SlotDurationSchedule] = ( + SlotDurationEntry(U64(0), Uint(10000)), +) + + +@final +@dataclass(frozen=True) +class BlobScheduleParameters: + """Blob parameters coupled to one slot-duration era.""" + + maximum: U64 + target: U64 + update_fraction: Uint + + +BASE_BLOB_SCHEDULE: Final[BlobScheduleParameters] = BlobScheduleParameters( + maximum=U64(21), + target=U64(14), + update_fraction=Uint(11_684_671), +) +"""Blob schedule in force before EIP-8198 activates.""" + + +def validate_slot_duration_schedule(schedule: SlotDurationSchedule) -> None: + """Validate ordering and duration constraints of a duration schedule.""" + previous_epoch: Optional[U64] = None + for entry in schedule: + if entry.duration_ms == 0 or entry.duration_ms % Uint(1000) != 0: + raise ValueError("slot duration must be a positive whole second") + if previous_epoch is not None and entry.epoch <= previous_epoch: + raise ValueError( + "slot duration epochs must be strictly increasing" + ) + previous_epoch = entry.epoch + + +def get_slot_duration_ms( + slot_number: U64, + schedule: SlotDurationSchedule = SLOT_DURATION_SCHEDULE, + base_duration_ms: Uint = BASE_SLOT_DURATION_MS, + slots_per_epoch: U64 = SLOTS_PER_EPOCH, +) -> Uint: + """Return the scheduled duration in effect for ``slot_number``.""" + validate_slot_duration_schedule(schedule) + if base_duration_ms == 0 or slots_per_epoch == 0: + raise ValueError("base duration and slots per epoch must be positive") + + epoch = slot_number // slots_per_epoch + duration_ms = base_duration_ms + for entry in schedule: + if epoch < entry.epoch: + break + duration_ms = entry.duration_ms + return duration_ms + + +def get_transition_durations( + parent_slot_number: Optional[U64], + current_slot_number: U64, + schedule: SlotDurationSchedule = SLOT_DURATION_SCHEDULE, + base_duration_ms: Uint = BASE_SLOT_DURATION_MS, +) -> Tuple[Uint, Uint]: + """ + Return parent/current execution-payload durations. + + ``None`` represents the legacy parent at the first EIP-8198 execution + payload. Future transitions use the parent execution payload's actual + slot, so missed slots and withheld payloads cannot suppress the change. + """ + new_duration_ms = get_slot_duration_ms( + current_slot_number, schedule, base_duration_ms + ) + if parent_slot_number is None: + old_duration_ms = base_duration_ms + else: + old_duration_ms = get_slot_duration_ms( + parent_slot_number, schedule, base_duration_ms + ) + return old_duration_ms, new_duration_ms + + +def scale_transition_limit( + value: Uint, old_duration_ms: Uint, new_duration_ms: Uint +) -> Uint: + """Scale a per-block capacity once when the duration era changes.""" + if old_duration_ms == 0 or new_duration_ms == 0: + raise ValueError("slot durations must be positive") + if old_duration_ms == new_duration_ms: + return value + return Uint(value * new_duration_ms // old_duration_ms) + + +def scale_blob_schedule( + previous: BlobScheduleParameters, + old_duration_ms: Uint, + new_duration_ms: Uint, +) -> BlobScheduleParameters: + """ + Derive blob parameters for the next duration era. + + Maximum blob count truncates down, target blob count rounds to nearest, + and the update fraction preserves maximum sustained blob-fee response + per unit of wall-clock time. + """ + if old_duration_ms == 0 or new_duration_ms == 0: + raise ValueError("slot durations must be positive") + if previous.maximum <= previous.target: + raise ValueError("blob maximum must exceed blob target") + + maximum = U64(Uint(previous.maximum) * new_duration_ms // old_duration_ms) + target = U64( + (Uint(previous.target) * new_duration_ms + old_duration_ms // Uint(2)) + // old_duration_ms + ) + if maximum <= target: + raise ValueError("scaled blob maximum must exceed scaled target") + + old_headroom = Uint(previous.maximum - previous.target) + new_headroom = Uint(maximum - target) + update_fraction = Uint( + previous.update_fraction + * new_headroom + * old_duration_ms + // (old_headroom * new_duration_ms) + ) + return BlobScheduleParameters(maximum, target, update_fraction) + + +def get_blob_schedule( + slot_number: U64, + schedule: SlotDurationSchedule = SLOT_DURATION_SCHEDULE, + base_duration_ms: Uint = BASE_SLOT_DURATION_MS, + base_blob_schedule: BlobScheduleParameters = BASE_BLOB_SCHEDULE, +) -> BlobScheduleParameters: + """Return blob parameters derived through every active duration era.""" + validate_slot_duration_schedule(schedule) + current_epoch = slot_number // SLOTS_PER_EPOCH + duration_ms = base_duration_ms + blob_schedule = base_blob_schedule + + for entry in schedule: + if current_epoch < entry.epoch: + break + if entry.duration_ms != duration_ms: + blob_schedule = scale_blob_schedule( + blob_schedule, duration_ms, entry.duration_ms + ) + duration_ms = entry.duration_ms + + return blob_schedule + + +def calculate_blob_gas_price_for_slot( + excess_blob_gas: U64, + slot_number: U64, + schedule: SlotDurationSchedule = SLOT_DURATION_SCHEDULE, +) -> Uint: + """Calculate the blob gas price using the current duration era.""" + blob_schedule = get_blob_schedule(slot_number, schedule) + return taylor_exponential( + BLOB_MIN_GASPRICE, + Uint(excess_blob_gas), + blob_schedule.update_fraction, + ) diff --git a/src/ethereum/forks/amsterdam/vm/gas.py b/src/ethereum/forks/amsterdam/vm/gas.py index 29a1f0c7c0b..64f6d33a354 100644 --- a/src/ethereum/forks/amsterdam/vm/gas.py +++ b/src/ethereum/forks/amsterdam/vm/gas.py @@ -18,10 +18,25 @@ from ethereum.forks.bpo5.blocks import Header as PreviousHeader from ethereum.trace import GasAndRefund, StateGasAndRefund, evm_trace -from ethereum.utils.numeric import ceil32, taylor_exponential +from ethereum.utils.numeric import ceil32 from ..blocks import Header from ..fork_types import StateGas, StateGasPerByte +from ..slot_timing import ( + BLOB_BASE_COST as SLOT_BLOB_BASE_COST, +) +from ..slot_timing import ( + BLOB_GAS_PER_BLOB as SLOT_BLOB_GAS_PER_BLOB, +) +from ..slot_timing import ( + BLOB_MIN_GASPRICE as SLOT_BLOB_MIN_GASPRICE, +) +from ..slot_timing import ( + SLOT_DURATION_SCHEDULE, + SlotDurationSchedule, + calculate_blob_gas_price_for_slot, + get_blob_schedule, +) from ..transactions import ( TX_MAX_GAS_LIMIT, BlobTransaction, @@ -34,6 +49,10 @@ from . import Evm +_INITIAL_SLOT = U64(0) +_INITIAL_BLOB_SCHEDULE = get_blob_schedule(_INITIAL_SLOT) + + # These may be patched at runtime by a future gas repricing utility to # fast-iterate on state-byte costs. class StateGasCosts: @@ -124,21 +143,19 @@ class GasCosts: # Blobs # - # The per-block blob schedule is rescaled from the previous fork's - # target of 14 and maximum of 21 by the slot-duration ratio - # (10s / 12s), keeping blob throughput per unit of wall-clock time - # approximately constant under the shorter slot: 21 * 10 // 12 = 17 - # and 14 * 10 / 12 = 11.67, rounded to 12. The update fraction is - # rescaled so that the maximum sustained blob base fee growth rate - # per unit of wall-clock time is preserved: - # 11684671 * (17 - 12) / (21 - 14) * 12 / 10 = 10015432. - PER_BLOB: Final[U64] = U64(2**17) - BLOB_SCHEDULE_TARGET: Final[U64] = U64(12) + # Slot-sensitive blob parameters come from SLOT_DURATION_SCHEDULE. + # These names retain the initial Amsterdam values only as a + # compatibility surface for callers that inspect GasCosts directly; + # protocol calculations below resolve the schedule for their slot. + PER_BLOB: Final[U64] = SLOT_BLOB_GAS_PER_BLOB + BLOB_SCHEDULE_TARGET: Final[U64] = _INITIAL_BLOB_SCHEDULE.target BLOB_TARGET_GAS_PER_BLOCK: Final[U64] = PER_BLOB * BLOB_SCHEDULE_TARGET - BLOB_BASE_COST: Final[Uint] = Uint(2**13) - BLOB_SCHEDULE_MAX: Final[U64] = U64(17) - BLOB_MIN_GASPRICE: Final[Uint] = Uint(1) - BLOB_BASE_FEE_UPDATE_FRACTION: Final[Uint] = Uint(10015432) + BLOB_BASE_COST: Final[Uint] = SLOT_BLOB_BASE_COST + BLOB_SCHEDULE_MAX: Final[U64] = _INITIAL_BLOB_SCHEDULE.maximum + BLOB_MIN_GASPRICE: Final[Uint] = SLOT_BLOB_MIN_GASPRICE + BLOB_BASE_FEE_UPDATE_FRACTION: Final[Uint] = ( + _INITIAL_BLOB_SCHEDULE.update_fraction + ) # Block Access Lists BLOCK_ACCESS_LIST_ITEM: Final[Uint] = Uint(2000) @@ -806,115 +823,76 @@ def init_code_cost(init_code_length: Uint) -> Uint: def calculate_excess_blob_gas( parent_header: Header | PreviousHeader, + current_slot_number: U64 = _INITIAL_SLOT, + slot_duration_schedule: SlotDurationSchedule = SLOT_DURATION_SCHEDULE, ) -> U64: - """ - Calculates the excess blob gas for the current block based - on the gas used in the parent block. - - Parameters - ---------- - parent_header : - The parent block of the current block. - - Returns - ------- - excess_blob_gas: `ethereum.base_types.U64` - The excess blob gas for the current block. - - """ - # At the fork block, these are defined as zero. + """Calculate excess blob gas using the current duration era.""" excess_blob_gas = U64(0) blob_gas_used = U64(0) base_fee_per_gas = Uint(0) if isinstance(parent_header, Header): - # After the fork block, read them from the parent header. excess_blob_gas = parent_header.excess_blob_gas blob_gas_used = parent_header.blob_gas_used base_fee_per_gas = parent_header.base_fee_per_gas + blob_schedule = get_blob_schedule( + current_slot_number, slot_duration_schedule + ) + target_blob_gas_per_block = GasCosts.PER_BLOB * blob_schedule.target parent_blob_gas = excess_blob_gas + blob_gas_used - if parent_blob_gas < GasCosts.BLOB_TARGET_GAS_PER_BLOCK: + if parent_blob_gas < target_blob_gas_per_block: return U64(0) target_blob_gas_price = Uint(GasCosts.PER_BLOB) - target_blob_gas_price *= calculate_blob_gas_price(excess_blob_gas) + target_blob_gas_price *= calculate_blob_gas_price( + excess_blob_gas, + current_slot_number, + slot_duration_schedule, + ) base_blob_tx_price = GasCosts.BLOB_BASE_COST * base_fee_per_gas if base_blob_tx_price > target_blob_gas_price: - blob_schedule_delta = ( - GasCosts.BLOB_SCHEDULE_MAX - GasCosts.BLOB_SCHEDULE_TARGET - ) - return ( + blob_schedule_delta = blob_schedule.maximum - blob_schedule.target + return U64( excess_blob_gas - + blob_gas_used * blob_schedule_delta // GasCosts.BLOB_SCHEDULE_MAX + + blob_gas_used * blob_schedule_delta // blob_schedule.maximum ) - return parent_blob_gas - GasCosts.BLOB_TARGET_GAS_PER_BLOCK + return U64(parent_blob_gas - target_blob_gas_per_block) def calculate_total_blob_gas(tx: Transaction) -> U64: - """ - Calculate the total blob gas for a transaction. - - Parameters - ---------- - tx : - The transaction for which the blob gas is to be calculated. - - Returns - ------- - total_blob_gas: `ethereum.base_types.Uint` - The total blob gas for the transaction. - - """ + """Calculate the total blob gas for a transaction.""" if isinstance(tx, BlobTransaction): return GasCosts.PER_BLOB * U64(len(tx.blob_versioned_hashes)) - else: - return U64(0) - - -def calculate_blob_gas_price(excess_blob_gas: U64) -> Uint: - """ - Calculate the blob gasprice for a block. - - Parameters - ---------- - excess_blob_gas : - The excess blob gas for the block. - - Returns - ------- - blob_gasprice: `Uint` - The blob gasprice. - - """ - return taylor_exponential( - GasCosts.BLOB_MIN_GASPRICE, - Uint(excess_blob_gas), - GasCosts.BLOB_BASE_FEE_UPDATE_FRACTION, + return U64(0) + + +def calculate_blob_gas_price( + excess_blob_gas: U64, + slot_number: U64 = _INITIAL_SLOT, + slot_duration_schedule: SlotDurationSchedule = SLOT_DURATION_SCHEDULE, +) -> Uint: + """Calculate the blob gas price for the supplied duration era.""" + return calculate_blob_gas_price_for_slot( + excess_blob_gas, + slot_number, + slot_duration_schedule, ) -def calculate_data_fee(excess_blob_gas: U64, tx: Transaction) -> Uint: - """ - Calculate the blob data fee for a transaction. - - Parameters - ---------- - excess_blob_gas : - The excess_blob_gas for the execution. - tx : - The transaction for which the blob data fee is to be calculated. - - Returns - ------- - data_fee: `Uint` - The blob data fee. - - """ +def calculate_data_fee( + excess_blob_gas: U64, + tx: Transaction, + slot_number: U64 = _INITIAL_SLOT, + slot_duration_schedule: SlotDurationSchedule = SLOT_DURATION_SCHEDULE, +) -> Uint: + """Calculate the blob data fee for the supplied duration era.""" return Uint(calculate_total_blob_gas(tx)) * calculate_blob_gas_price( - excess_blob_gas + excess_blob_gas, + slot_number, + slot_duration_schedule, ) diff --git a/src/ethereum/forks/amsterdam/vm/instructions/environment.py b/src/ethereum/forks/amsterdam/vm/instructions/environment.py index 8a7e9ec1486..422af8d7b37 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/environment.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/environment.py @@ -17,212 +17,79 @@ from ethereum.state import EMPTY_ACCOUNT from ethereum.utils.numeric import ceil32 +from ...slot_timing import calculate_blob_gas_price_for_slot from ...state_tracker import get_account, get_code from ...utils.address import to_address_masked from ...vm.memory import buffer_read, memory_write from .. import Evm from ..exceptions import OutOfBoundsRead -from ..gas import ( - GasCosts, - calculate_blob_gas_price, - calculate_gas_extend_memory, - charge_gas, -) +from ..gas import GasCosts, calculate_gas_extend_memory, charge_gas from ..stack import pop, push def address(evm: Evm) -> None: - """ - Pushes the address of the current executing account to the stack. - - Parameters - ---------- - evm : - The current EVM frame. - - """ - # STACK - pass - - # GAS + """Push the address of the current executing account to the stack.""" charge_gas(evm, GasCosts.OPCODE_ADDRESS) - - # OPERATION push(evm.stack, U256.from_be_bytes(evm.message.current_target)) - - # PROGRAM COUNTER evm.pc += Uint(1) def balance(evm: Evm) -> None: - """ - Pushes the balance of the given account onto the stack. - - Parameters - ---------- - evm : - The current EVM frame. - - """ - # STACK + """Push the balance of the given account onto the stack.""" address = to_address_masked(pop(evm.stack)) - - # GAS if address in evm.accessed_addresses: charge_gas(evm, GasCosts.WARM_ACCESS) else: evm.accessed_addresses.add(address) charge_gas(evm, GasCosts.COLD_ACCOUNT_ACCESS) - - # OPERATION - # Non-existent accounts default to EMPTY_ACCOUNT, which has balance 0. tx_state = evm.message.tx_env.state balance = get_account(tx_state, address).balance - push(evm.stack, balance) - - # PROGRAM COUNTER evm.pc += Uint(1) def origin(evm: Evm) -> None: - """ - Pushes the address of the original transaction sender to the stack. - The origin address can only be an EOA. - - Parameters - ---------- - evm : - The current EVM frame. - - """ - # STACK - pass - - # GAS + """Push the original transaction sender to the stack.""" charge_gas(evm, GasCosts.OPCODE_ORIGIN) - - # OPERATION push(evm.stack, U256.from_be_bytes(evm.message.tx_env.origin)) - - # PROGRAM COUNTER evm.pc += Uint(1) def caller(evm: Evm) -> None: - """ - Pushes the address of the caller onto the stack. - - Parameters - ---------- - evm : - The current EVM frame. - - """ - # STACK - pass - - # GAS + """Push the address of the caller onto the stack.""" charge_gas(evm, GasCosts.OPCODE_CALLER) - - # OPERATION push(evm.stack, U256.from_be_bytes(evm.message.caller)) - - # PROGRAM COUNTER evm.pc += Uint(1) def callvalue(evm: Evm) -> None: - """ - Push the value (in wei) sent with the call onto the stack. - - Parameters - ---------- - evm : - The current EVM frame. - - """ - # STACK - pass - - # GAS + """Push the value sent with the call onto the stack.""" charge_gas(evm, GasCosts.OPCODE_CALLVALUE) - - # OPERATION push(evm.stack, evm.message.value) - - # PROGRAM COUNTER evm.pc += Uint(1) def calldataload(evm: Evm) -> None: - """ - Push a word (32 bytes) of the input data belonging to the current - environment onto the stack. - - Parameters - ---------- - evm : - The current EVM frame. - - """ - # STACK + """Push a word of the current call data onto the stack.""" start_index = pop(evm.stack) - - # GAS charge_gas(evm, GasCosts.OPCODE_CALLDATALOAD) - - # OPERATION value = buffer_read(evm.message.data, start_index, U256(32)) - push(evm.stack, U256.from_be_bytes(value)) - - # PROGRAM COUNTER evm.pc += Uint(1) def calldatasize(evm: Evm) -> None: - """ - Push the size of input data in current environment onto the stack. - - Parameters - ---------- - evm : - The current EVM frame. - - """ - # STACK - pass - - # GAS + """Push the size of the current call data onto the stack.""" charge_gas(evm, GasCosts.OPCODE_CALLDATASIZE) - - # OPERATION push(evm.stack, U256(len(evm.message.data))) - - # PROGRAM COUNTER evm.pc += Uint(1) def calldatacopy(evm: Evm) -> None: - """ - Copy a portion of the input data in current environment to memory. - - This will also expand the memory, in case that the memory is insufficient - to store the data. - - Parameters - ---------- - evm : - The current EVM frame. - - """ - # STACK + """Copy a portion of call data to memory.""" memory_start_index = pop(evm.stack) data_start_index = pop(evm.stack) size = pop(evm.stack) - - # GAS words = ceil32(Uint(size)) // Uint(32) copy_gas_cost = GasCosts.OPCODE_COPY_PER_WORD * words extend_memory = calculate_gas_extend_memory( @@ -232,58 +99,24 @@ def calldatacopy(evm: Evm) -> None: evm, GasCosts.OPCODE_CALLDATACOPY_BASE + copy_gas_cost + extend_memory.cost, ) - - # OPERATION evm.memory += b"\x00" * extend_memory.expand_by value = buffer_read(evm.message.data, data_start_index, size) memory_write(evm.memory, memory_start_index, value) - - # PROGRAM COUNTER evm.pc += Uint(1) def codesize(evm: Evm) -> None: - """ - Push the size of code running in current environment onto the stack. - - Parameters - ---------- - evm : - The current EVM frame. - - """ - # STACK - pass - - # GAS + """Push the size of the current code onto the stack.""" charge_gas(evm, GasCosts.OPCODE_CODESIZE) - - # OPERATION push(evm.stack, U256(len(evm.code))) - - # PROGRAM COUNTER evm.pc += Uint(1) def codecopy(evm: Evm) -> None: - """ - Copy a portion of the code in current environment to memory. - - This will also expand the memory, in case that the memory is insufficient - to store the data. - - Parameters - ---------- - evm : - The current EVM frame. - - """ - # STACK + """Copy a portion of current code to memory.""" memory_start_index = pop(evm.stack) code_start_index = pop(evm.stack) size = pop(evm.stack) - - # GAS words = ceil32(Uint(size)) // Uint(32) copy_gas_cost = GasCosts.OPCODE_COPY_PER_WORD * words extend_memory = calculate_gas_extend_memory( @@ -293,159 +126,75 @@ def codecopy(evm: Evm) -> None: evm, GasCosts.OPCODE_CODECOPY_BASE + copy_gas_cost + extend_memory.cost, ) - - # OPERATION evm.memory += b"\x00" * extend_memory.expand_by value = buffer_read(evm.code, code_start_index, size) memory_write(evm.memory, memory_start_index, value) - - # PROGRAM COUNTER evm.pc += Uint(1) def gasprice(evm: Evm) -> None: - """ - Push the gas price used in current environment onto the stack. - - Parameters - ---------- - evm : - The current EVM frame. - - """ - # STACK - pass - - # GAS + """Push the transaction gas price onto the stack.""" charge_gas(evm, GasCosts.OPCODE_GASPRICE) - - # OPERATION push(evm.stack, U256(evm.message.tx_env.gas_price)) - - # PROGRAM COUNTER evm.pc += Uint(1) def extcodesize(evm: Evm) -> None: - """ - Push the code size of a given account onto the stack. - - Parameters - ---------- - evm : - The current EVM frame. - - """ - # STACK + """Push the code size of a given account onto the stack.""" address = to_address_masked(pop(evm.stack)) - - # GAS if address in evm.accessed_addresses: access_gas_cost = GasCosts.WARM_ACCESS else: evm.accessed_addresses.add(address) access_gas_cost = GasCosts.COLD_ACCOUNT_ACCESS - access_gas_cost += GasCosts.WARM_ACCESS # Code reading cost (EIP-8038) + access_gas_cost += GasCosts.WARM_ACCESS charge_gas(evm, access_gas_cost) - - # OPERATION tx_state = evm.message.tx_env.state code_hash = get_account(tx_state, address).code_hash code = get_code(tx_state, code_hash) - - codesize = U256(len(code)) - push(evm.stack, codesize) - - # PROGRAM COUNTER + push(evm.stack, U256(len(code))) evm.pc += Uint(1) def extcodecopy(evm: Evm) -> None: - """ - Copy a portion of an account's code to memory. - - Parameters - ---------- - evm : - The current EVM frame. - - """ - # STACK + """Copy a portion of an account's code to memory.""" address = to_address_masked(pop(evm.stack)) memory_start_index = pop(evm.stack) code_start_index = pop(evm.stack) size = pop(evm.stack) - - # GAS words = ceil32(Uint(size)) // Uint(32) copy_gas_cost = GasCosts.OPCODE_COPY_PER_WORD * words extend_memory = calculate_gas_extend_memory( evm.memory, [(memory_start_index, size)] ) - if address in evm.accessed_addresses: access_gas_cost = GasCosts.WARM_ACCESS else: evm.accessed_addresses.add(address) access_gas_cost = GasCosts.COLD_ACCOUNT_ACCESS - access_gas_cost += GasCosts.WARM_ACCESS # Code reading cost (EIP-8038) - - total_gas_cost = access_gas_cost + copy_gas_cost + extend_memory.cost - - charge_gas(evm, total_gas_cost) - - # OPERATION + access_gas_cost += GasCosts.WARM_ACCESS + charge_gas(evm, access_gas_cost + copy_gas_cost + extend_memory.cost) evm.memory += b"\x00" * extend_memory.expand_by tx_state = evm.message.tx_env.state code_hash = get_account(tx_state, address).code_hash code = get_code(tx_state, code_hash) - value = buffer_read(code, code_start_index, size) memory_write(evm.memory, memory_start_index, value) - - # PROGRAM COUNTER evm.pc += Uint(1) def returndatasize(evm: Evm) -> None: - """ - Pushes the size of the return data buffer onto the stack. - - Parameters - ---------- - evm : - The current EVM frame. - - """ - # STACK - pass - - # GAS + """Push the size of the return data buffer onto the stack.""" charge_gas(evm, GasCosts.OPCODE_RETURNDATASIZE) - - # OPERATION push(evm.stack, U256(len(evm.return_data))) - - # PROGRAM COUNTER evm.pc += Uint(1) def returndatacopy(evm: Evm) -> None: - """ - Copies data from the return data buffer to memory. - - Parameters - ---------- - evm : - The current EVM frame. - - """ - # STACK + """Copy data from the return-data buffer to memory.""" memory_start_index = pop(evm.stack) return_data_start_position = pop(evm.stack) size = pop(evm.stack) - - # GAS words = ceil32(Uint(size)) // Uint(32) copy_gas_cost = GasCosts.OPCODE_RETURNDATACOPY_PER_WORD * words extend_memory = calculate_gas_extend_memory( @@ -459,153 +208,69 @@ def returndatacopy(evm: Evm) -> None: ) if Uint(return_data_start_position) + Uint(size) > ulen(evm.return_data): raise OutOfBoundsRead - evm.memory += b"\x00" * extend_memory.expand_by value = evm.return_data[ return_data_start_position : return_data_start_position + size ] memory_write(evm.memory, memory_start_index, value) - - # PROGRAM COUNTER evm.pc += Uint(1) def extcodehash(evm: Evm) -> None: - """ - Returns the keccak256 hash of a contract’s bytecode. - - Parameters - ---------- - evm : - The current EVM frame. - - """ - # STACK + """Push the keccak256 hash of an account's bytecode.""" address = to_address_masked(pop(evm.stack)) - - # GAS if address in evm.accessed_addresses: access_gas_cost = GasCosts.WARM_ACCESS else: evm.accessed_addresses.add(address) access_gas_cost = GasCosts.COLD_ACCOUNT_ACCESS - charge_gas(evm, access_gas_cost) - - # OPERATION tx_state = evm.message.tx_env.state account = get_account(tx_state, address) - if account == EMPTY_ACCOUNT: codehash = U256(0) else: codehash = U256.from_be_bytes(account.code_hash) - push(evm.stack, codehash) - - # PROGRAM COUNTER evm.pc += Uint(1) def self_balance(evm: Evm) -> None: - """ - Pushes the balance of the current address to the stack. - - Parameters - ---------- - evm : - The current EVM frame. - - """ - # STACK - pass - - # GAS + """Push the current account balance onto the stack.""" charge_gas(evm, GasCosts.FAST_STEP) - - # OPERATION - # Non-existent accounts default to EMPTY_ACCOUNT, which has balance 0. balance = get_account( evm.message.tx_env.state, evm.message.current_target ).balance - push(evm.stack, balance) - - # PROGRAM COUNTER evm.pc += Uint(1) def base_fee(evm: Evm) -> None: - """ - Pushes the base fee of the current block on to the stack. - - Parameters - ---------- - evm : - The current EVM frame. - - """ - # STACK - pass - - # GAS + """Push the current block base fee onto the stack.""" charge_gas(evm, GasCosts.OPCODE_BASEFEE) - - # OPERATION push(evm.stack, U256(evm.message.block_env.base_fee_per_gas)) - - # PROGRAM COUNTER evm.pc += Uint(1) def blob_hash(evm: Evm) -> None: - """ - Pushes the versioned hash at a particular index on to the stack. - - Parameters - ---------- - evm : - The current EVM frame. - - """ - # STACK + """Push the versioned blob hash at an index onto the stack.""" index = pop(evm.stack) - - # GAS charge_gas(evm, GasCosts.OPCODE_BLOBHASH) - - # OPERATION if int(index) < len(evm.message.tx_env.blob_versioned_hashes): blob_hash = evm.message.tx_env.blob_versioned_hashes[index] else: blob_hash = Bytes32(b"\x00" * 32) push(evm.stack, U256.from_be_bytes(blob_hash)) - - # PROGRAM COUNTER evm.pc += Uint(1) def blob_base_fee(evm: Evm) -> None: - """ - Pushes the blob base fee on to the stack. - - Parameters - ---------- - evm : - The current EVM frame. - - """ - # STACK - pass - - # GAS + """Push the blob base fee for the current slot-duration era.""" charge_gas(evm, GasCosts.OPCODE_BLOBBASEFEE) - - # OPERATION - blob_base_fee = calculate_blob_gas_price( - evm.message.block_env.excess_blob_gas + block_env = evm.message.block_env + blob_base_fee = calculate_blob_gas_price_for_slot( + block_env.excess_blob_gas, + block_env.slot_number, ) push(evm.stack, U256(blob_base_fee)) - - # PROGRAM COUNTER evm.pc += Uint(1) diff --git a/src/ethereum_spec_tools/forks.py b/src/ethereum_spec_tools/forks.py index e32433c115b..1432dcac551 100644 --- a/src/ethereum_spec_tools/forks.py +++ b/src/ethereum_spec_tools/forks.py @@ -116,12 +116,22 @@ def matches_template( fork_mod = template.module("fork") gas_costs = template.module("vm.gas").GasCosts + max_blob_gas_per_block = getattr( + fork_mod, "MAX_BLOB_GAS_PER_BLOCK", None + ) + if max_blob_gas_per_block is None: + get_max_blob_gas_per_block = getattr( + fork_mod, "get_max_blob_gas_per_block", None + ) + if get_max_blob_gas_per_block is not None: + max_blob_gas_per_block = get_max_blob_gas_per_block(U64(0)) + if ( + self.max_blob_gas_per_block is not None + and self.max_blob_gas_per_block != max_blob_gas_per_block + ): + return False + checks = ( - ( - self.max_blob_gas_per_block, - fork_mod, - "MAX_BLOB_GAS_PER_BLOCK", - ), ( self.blob_target_gas_per_block, gas_costs, diff --git a/src/ethereum_spec_tools/new_fork/builder.py b/src/ethereum_spec_tools/new_fork/builder.py index 8883ac4b556..2bd851f7327 100644 --- a/src/ethereum_spec_tools/new_fork/builder.py +++ b/src/ethereum_spec_tools/new_fork/builder.py @@ -504,10 +504,27 @@ def build(self) -> None: self._modify(working_directory) self._commit(package) + def _uses_slot_duration_schedule(self) -> bool: + """Return whether the template derives blob limits from slot time.""" + template_path = self.template_fork.path + return ( + template_path is not None + and (Path(template_path) / "slot_timing.py").is_file() + ) + + def _reject_legacy_blob_override(self, name: str) -> None: + """Reject fixed-blob overrides on schedule-driven forks.""" + if self._uses_slot_duration_schedule(): + raise ValueError( + f"{name} is a legacy fixed-blob override and cannot be " + "applied to a SLOT_DURATION_SCHEDULE-driven fork" + ) + def modify_target_blob_gas_per_block( self, blob_target_gas_per_block: U64 ) -> None: """Append a `CodemodArgs` that sets `BLOB_TARGET_GAS_PER_BLOCK`.""" + self._reject_legacy_blob_override("blob_target_gas_per_block") self.modifiers.append( SetConstant( "vm.gas.GasCosts.BLOB_TARGET_GAS_PER_BLOCK", @@ -517,6 +534,7 @@ def modify_target_blob_gas_per_block( def modify_gas_per_blob(self, gas_per_blob: U64) -> None: """Append a `CodemodArgs` that sets `PER_BLOB`.""" + self._reject_legacy_blob_override("gas_per_blob") self.modifiers.append( SetConstant( "vm.gas.GasCosts.PER_BLOB", @@ -526,6 +544,7 @@ def modify_gas_per_blob(self, gas_per_blob: U64) -> None: def modify_min_blob_gasprice(self, blob_min_gasprice: Uint) -> None: """Append a `CodemodArgs` that sets `BLOB_MIN_GASPRICE`.""" + self._reject_legacy_blob_override("blob_min_gasprice") self.modifiers.append( SetConstant( "vm.gas.GasCosts.BLOB_MIN_GASPRICE", @@ -537,6 +556,7 @@ def modify_blob_base_fee_update_fraction( self, blob_base_fee_update_fraction: Uint ) -> None: """Append a `CodemodArgs` that sets `BLOB_BASE_FEE_UPDATE_FRACTION`.""" + self._reject_legacy_blob_override("blob_base_fee_update_fraction") self.modifiers.append( SetConstant( "vm.gas.GasCosts.BLOB_BASE_FEE_UPDATE_FRACTION", @@ -548,6 +568,7 @@ def modify_max_blob_gas_per_block( self, max_blob_gas_per_block: U64 ) -> None: """Append a `CodemodArgs` that sets `MAX_BLOB_GAS_PER_BLOCK`.""" + self._reject_legacy_blob_override("max_blob_gas_per_block") self.modifiers.append( SetConstant( "fork.MAX_BLOB_GAS_PER_BLOCK", @@ -557,6 +578,7 @@ def modify_max_blob_gas_per_block( def modify_blob_schedule_target(self, blob_schedule_target: U64) -> None: """Append a `CodemodArgs` that sets `BLOB_SCHEDULE_TARGET`.""" + self._reject_legacy_blob_override("blob_schedule_target") self.modifiers.append( SetConstant( "vm.gas.GasCosts.BLOB_SCHEDULE_TARGET", @@ -566,6 +588,7 @@ def modify_blob_schedule_target(self, blob_schedule_target: U64) -> None: def modify_blob_schedule_max(self, blob_schedule_max: U64) -> None: """Append a `CodemodArgs` that sets `BLOB_SCHEDULE_MAX`.""" + self._reject_legacy_blob_override("blob_schedule_max") self.modifiers.append( SetConstant( "vm.gas.GasCosts.BLOB_SCHEDULE_MAX", diff --git a/tests/amsterdam/eip8198_quick_slots/__init__.py b/tests/amsterdam/eip8198_quick_slots/__init__.py new file mode 100644 index 00000000000..d823c50cd79 --- /dev/null +++ b/tests/amsterdam/eip8198_quick_slots/__init__.py @@ -0,0 +1 @@ +"""Tests for EIP-8198 slot-duration independence.""" diff --git a/tests/amsterdam/eip8198_quick_slots/test_slot_timing.py b/tests/amsterdam/eip8198_quick_slots/test_slot_timing.py new file mode 100644 index 00000000000..5ec7e2ecb72 --- /dev/null +++ b/tests/amsterdam/eip8198_quick_slots/test_slot_timing.py @@ -0,0 +1,258 @@ +"""Tests that EIP-8198 duration changes are schedule-driven.""" + +import inspect +from dataclasses import replace + +import pytest +from ethereum_rlp import rlp +from ethereum_types.bytes import Bytes, Bytes8, Bytes32 +from ethereum_types.numeric import U64, U256, Uint + +from ethereum.crypto.hash import Hash32, keccak256 +from ethereum.exceptions import InvalidBlock +from ethereum.forks.amsterdam import fork as amsterdam_fork +from ethereum.forks.amsterdam.blocks import Header +from ethereum.forks.amsterdam.fork import ( + EMPTY_OMMER_HASH, + calculate_base_fee_per_gas, + get_max_blob_gas_per_block, + validate_header, +) +from ethereum.forks.amsterdam.fork_types import Bloom +from ethereum.forks.amsterdam.slot_timing import ( + BLOB_GAS_PER_BLOB, + BlobScheduleParameters, + SlotDurationEntry, + calculate_blob_gas_price_for_slot, + get_blob_schedule, + get_slot_duration_ms, + get_transition_durations, + scale_blob_schedule, + scale_transition_limit, +) +from ethereum.state import Address, Root + +HEGOTA_EPOCH = U64(10) +FUTURE_TEST_EPOCH = U64(20) +SCHEDULE_12_10_8 = ( + SlotDurationEntry(HEGOTA_EPOCH, Uint(10000)), + SlotDurationEntry(FUTURE_TEST_EPOCH, Uint(8000)), +) +ZERO_ROOT = Root(b"\x00" * 32) +ZERO_HASH = Hash32(b"\x00" * 32) + + +def _header( + *, + slot_number: U64, + number: Uint, + gas_limit: Uint, + gas_used: Uint, + base_fee_per_gas: Uint, + timestamp: U256, +) -> Header: + """Build a minimal Amsterdam header for duration-transition tests.""" + return Header( + parent_hash=ZERO_HASH, + ommers_hash=EMPTY_OMMER_HASH, + coinbase=Address(b"\x00" * 20), + state_root=ZERO_ROOT, + transactions_root=ZERO_ROOT, + receipt_root=ZERO_ROOT, + bloom=Bloom(b"\x00" * 256), + difficulty=Uint(0), + number=number, + gas_limit=gas_limit, + gas_used=gas_used, + timestamp=timestamp, + extra_data=Bytes(b""), + prev_randao=Bytes32(b"\x00" * 32), + nonce=Bytes8(b"\x00" * 8), + base_fee_per_gas=base_fee_per_gas, + withdrawals_root=ZERO_ROOT, + blob_gas_used=U64(0), + excess_blob_gas=U64(0), + parent_beacon_block_root=ZERO_ROOT, + requests_hash=ZERO_HASH, + block_access_list_hash=ZERO_HASH, + slot_number=slot_number, + ) + + +def test_repeated_duration_changes_are_schedule_only() -> None: + """A synthetic 10 -> 8 era uses the same lookup as 12 -> 10.""" + last_12s_slot = U64(HEGOTA_EPOCH * U64(32) - U64(1)) + first_10s_slot = U64(HEGOTA_EPOCH * U64(32)) + last_10s_slot = U64(FUTURE_TEST_EPOCH * U64(32) - U64(1)) + first_8s_slot = U64(FUTURE_TEST_EPOCH * U64(32)) + + assert get_slot_duration_ms(last_12s_slot, SCHEDULE_12_10_8) == Uint(12000) + assert get_slot_duration_ms(first_10s_slot, SCHEDULE_12_10_8) == Uint( + 10000 + ) + assert get_slot_duration_ms(last_10s_slot, SCHEDULE_12_10_8) == Uint(10000) + assert get_slot_duration_ms(first_8s_slot, SCHEDULE_12_10_8) == Uint(8000) + + +def test_gas_limit_scales_once_at_each_duration_boundary() -> None: + """Gas/sec is preserved at both 12 -> 10 and 10 -> 8 transitions.""" + first_10s_slot = U64(HEGOTA_EPOCH * U64(32)) + first_8s_slot = U64(FUTURE_TEST_EPOCH * U64(32)) + last_10s_payload_slot = U64(first_8s_slot - U64(7)) + + old_ms, new_ms = get_transition_durations( + None, first_10s_slot, SCHEDULE_12_10_8 + ) + gas_limit_10s = scale_transition_limit(Uint(72_000_000), old_ms, new_ms) + assert (old_ms, new_ms) == (Uint(12000), Uint(10000)) + assert gas_limit_10s == Uint(60_000_000) + + old_ms, new_ms = get_transition_durations( + last_10s_payload_slot, + U64(first_8s_slot + U64(7)), + SCHEDULE_12_10_8, + ) + gas_limit_8s = scale_transition_limit(gas_limit_10s, old_ms, new_ms) + assert (old_ms, new_ms) == (Uint(10000), Uint(8000)) + assert gas_limit_8s == Uint(48_000_000) + + old_ms, new_ms = get_transition_durations( + U64(first_8s_slot + U64(7)), + U64(first_8s_slot + U64(19)), + SCHEDULE_12_10_8, + ) + assert scale_transition_limit(gas_limit_8s, old_ms, new_ms) == gas_limit_8s + + +def test_validate_header_handles_missed_payloads_at_second_boundary() -> None: + """ + Validate the production header path across a synthetic 10 -> 8 change. + + The parent execution payload is seven slots before the boundary and the + child payload is seven slots after it. The duration transition must still + scale the gas limit exactly once, even though no payload exists at the + scheduled boundary slot. + """ + first_8s_slot = U64(FUTURE_TEST_EPOCH * U64(32)) + parent = _header( + slot_number=U64(first_8s_slot - U64(7)), + number=Uint(100), + gas_limit=Uint(60_000_000), + gas_used=Uint(30_000_000), + base_fee_per_gas=Uint(960), + timestamp=U256(1_000_000), + ) + transition = _header( + slot_number=U64(first_8s_slot + U64(7)), + number=Uint(101), + gas_limit=Uint(48_000_000), + gas_used=Uint(24_000_000), + base_fee_per_gas=Uint(960), + timestamp=U256(1_000_008), + ) + transition = replace( + transition, + parent_hash=keccak256(rlp.encode(parent)), + ) + + validate_header(parent, transition, SCHEDULE_12_10_8) + + unscaled = replace(transition, gas_limit=Uint(60_000_000)) + with pytest.raises(InvalidBlock): + validate_header(parent, unscaled, SCHEDULE_12_10_8) + + ordinary_8s = _header( + slot_number=U64(first_8s_slot + U64(19)), + number=Uint(102), + gas_limit=Uint(48_000_000), + gas_used=Uint(24_000_000), + base_fee_per_gas=Uint(960), + timestamp=U256(1_000_016), + ) + ordinary_8s = replace( + ordinary_8s, + parent_hash=keccak256(rlp.encode(transition)), + ) + validate_header(transition, ordinary_8s, SCHEDULE_12_10_8) + + +def test_production_base_fee_path_supports_second_era() -> None: + """Amsterdam's real base-fee calculator remains wall-clock invariant.""" + common = dict( + block_gas_limit=Uint(60_000_000), + parent_gas_limit=Uint(60_000_000), + parent_gas_used=Uint(60_000_000), + parent_base_fee_per_gas=Uint(960), + gas_limit_reference=Uint(60_000_000), + ) + + fee_10s = calculate_base_fee_per_gas( + **common, + slot_duration_ms=Uint(10000), + ) + fee_8s = calculate_base_fee_per_gas( + **common, + slot_duration_ms=Uint(8000), + ) + + assert fee_10s == Uint(1060) + assert fee_8s == Uint(1040) + + +def test_blob_schedule_derives_repeated_eras_from_same_transition() -> None: + """Blob throughput and fee response derive through 12 -> 10 -> 8.""" + blob_12s = BlobScheduleParameters( + maximum=U64(21), + target=U64(14), + update_fraction=Uint(11_684_671), + ) + + blob_10s = scale_blob_schedule(blob_12s, Uint(12000), Uint(10000)) + assert blob_10s == BlobScheduleParameters( + maximum=U64(17), + target=U64(12), + update_fraction=Uint(10_015_432), + ) + + blob_8s = scale_blob_schedule(blob_10s, Uint(10000), Uint(8000)) + assert blob_8s == BlobScheduleParameters( + maximum=U64(13), + target=U64(10), + update_fraction=Uint(7_511_574), + ) + + +def test_production_blob_paths_follow_same_schedule() -> None: + """Capacity and BLOBBASEFEE inputs both follow the synthetic 8s era.""" + first_10s_slot = U64(HEGOTA_EPOCH * U64(32)) + first_8s_slot = U64(FUTURE_TEST_EPOCH * U64(32)) + + blob_10s = get_blob_schedule(first_10s_slot, SCHEDULE_12_10_8) + blob_8s = get_blob_schedule(first_8s_slot, SCHEDULE_12_10_8) + assert blob_10s.maximum == U64(17) + assert blob_8s.maximum == U64(13) + + assert get_max_blob_gas_per_block( + first_10s_slot, SCHEDULE_12_10_8 + ) == BLOB_GAS_PER_BLOB * U64(17) + assert get_max_blob_gas_per_block( + first_8s_slot, SCHEDULE_12_10_8 + ) == BLOB_GAS_PER_BLOB * U64(13) + + excess = U64(20_000_000) + fee_10s = calculate_blob_gas_price_for_slot( + excess, first_10s_slot, SCHEDULE_12_10_8 + ) + fee_8s = calculate_blob_gas_price_for_slot( + excess, first_8s_slot, SCHEDULE_12_10_8 + ) + assert fee_8s != fee_10s + + +def test_amsterdam_has_no_12_to_10_transition_special_case() -> None: + """The production header path contains no previous-duration constant.""" + source = inspect.getsource(amsterdam_fork) + assert "PREVIOUS_SLOT_DURATION_MS" not in source + assert "SLOT_DURATION_MS = Uint(10000)" not in source + assert "get_transition_durations" in source + assert "scale_transition_limit" in source diff --git a/tests/amsterdam/eip8198_quick_slots/test_vm_blob_schedule.py b/tests/amsterdam/eip8198_quick_slots/test_vm_blob_schedule.py new file mode 100644 index 00000000000..db3170cc068 --- /dev/null +++ b/tests/amsterdam/eip8198_quick_slots/test_vm_blob_schedule.py @@ -0,0 +1,59 @@ +"""Prove VM blob helpers remain slot-duration agnostic.""" + +import inspect + +from ethereum_types.numeric import U64, Uint + +from ethereum.forks.amsterdam.slot_timing import ( + SlotDurationEntry, + calculate_blob_gas_price_for_slot, +) +from ethereum.forks.amsterdam.vm import gas as vm_gas + +FUTURE_TEST_EPOCH = U64(20) +SCHEDULE_10_8 = ( + SlotDurationEntry(U64(0), Uint(10000)), + SlotDurationEntry(FUTURE_TEST_EPOCH, Uint(8000)), +) + + +def test_vm_blob_price_uses_slot_schedule_for_future_era() -> None: + """The VM compatibility helper follows the same 10 -> 8 schedule.""" + last_10s_slot = U64(FUTURE_TEST_EPOCH * U64(32) - U64(1)) + first_8s_slot = U64(FUTURE_TEST_EPOCH * U64(32)) + excess_blob_gas = U64(20_000_000) + + vm_10s = vm_gas.calculate_blob_gas_price( + excess_blob_gas, + last_10s_slot, + SCHEDULE_10_8, + ) + vm_8s = vm_gas.calculate_blob_gas_price( + excess_blob_gas, + first_8s_slot, + SCHEDULE_10_8, + ) + + assert vm_10s == calculate_blob_gas_price_for_slot( + excess_blob_gas, + last_10s_slot, + SCHEDULE_10_8, + ) + assert vm_8s == calculate_blob_gas_price_for_slot( + excess_blob_gas, + first_8s_slot, + SCHEDULE_10_8, + ) + assert vm_8s != vm_10s + + +def test_vm_blob_protocol_calculators_do_not_use_initial_snapshot() -> None: + """Initial-era compatibility constants cannot govern later eras.""" + excess_source = inspect.getsource(vm_gas.calculate_excess_blob_gas) + price_source = inspect.getsource(vm_gas.calculate_blob_gas_price) + + assert "get_blob_schedule" in excess_source + assert "BLOB_SCHEDULE_TARGET" not in excess_source + assert "BLOB_SCHEDULE_MAX" not in excess_source + assert "calculate_blob_gas_price_for_slot" in price_source + assert "BLOB_BASE_FEE_UPDATE_FRACTION" not in price_source diff --git a/tests/evm_tools/test_fork_cache.py b/tests/evm_tools/test_fork_cache.py index 516943aac5c..a9249f0a452 100644 --- a/tests/evm_tools/test_fork_cache.py +++ b/tests/evm_tools/test_fork_cache.py @@ -88,6 +88,14 @@ def _override_defaults(template: Hardfork) -> dict[str, U64 | Uint]: gas_mod = template.module("vm.gas") fork_mod = template.module("fork") + max_blob_gas_per_block = getattr(fork_mod, "MAX_BLOB_GAS_PER_BLOCK", None) + if max_blob_gas_per_block is None: + get_max_blob_gas_per_block = getattr( + fork_mod, "get_max_blob_gas_per_block", None + ) + if get_max_blob_gas_per_block is not None: + max_blob_gas_per_block = get_max_blob_gas_per_block(U64(0)) + defaults: dict[str, U64 | Uint | None] = { "blob_target_gas_per_block": _gas_default( gas_mod, @@ -99,11 +107,7 @@ def _override_defaults(template: Hardfork) -> dict[str, U64 | Uint]: gas_mod, "BLOB_BASE_FEE_UPDATE_FRACTION", ), - "max_blob_gas_per_block": getattr( - fork_mod, - "MAX_BLOB_GAS_PER_BLOCK", - None, - ), + "max_blob_gas_per_block": max_blob_gas_per_block, "blob_schedule_target": _gas_default(gas_mod, "BLOB_SCHEDULE_TARGET"), "blob_schedule_max": _gas_default(gas_mod, "BLOB_SCHEDULE_MAX"), } @@ -250,6 +254,14 @@ def clone( assert getattr(_seen_overrides(seen), field) == changed_value +def test_schedule_driven_clone_rejects_legacy_blob_override() -> None: + """Never create a clone whose fixed blob aliases lie about runtime.""" + template = _template() + changed = ForkOverrides(blob_schedule_target=U64(13)) + with pytest.raises(ValueError, match="SLOT_DURATION_SCHEDULE"): + Hardfork.clone(template=template, overrides=changed) + + def test_fork_cache_reuses_cached_clone_for_identical_changed_request( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/evm_tools/test_new_fork.py b/tests/evm_tools/test_new_fork.py index 45e76591af5..9b379a70f57 100644 --- a/tests/evm_tools/test_new_fork.py +++ b/tests/evm_tools/test_new_fork.py @@ -25,44 +25,49 @@ ids=lambda tf: f"{tf}", ) def test_end_to_end(template_fork: str) -> None: - """ - Test that the ethereum-spec-new-fork CLI tool creates a fork from a - template, correctly modifying names, blob parameters, and imports. - """ + """Create forks without imposing fixed blob literals on schedules.""" with TemporaryDirectory() as base_dir: output_dir = Path(base_dir) / "ethereum" fork_dir = output_dir / "e2e_fork" + template_path = Path("src/ethereum/forks") / template_fork + uses_slot_duration_schedule = ( + template_path / "slot_timing.py" + ).exists() - new_fork( - [ - "--new-fork", - "e2e_fork", - "--template-fork", - template_fork, - "--target-blob-gas-per-block", - "199", - "--blob-base-fee-update-fraction", - "750", - "--min-blob-gasprice", - "2", - "--gas-per-blob", - "1", - "--at-timestamp", - "7", - "--max-blob-gas-per-block", - "99", - "--blob-schedule-target", - "88", - "--blob-schedule-max", - "77", - "--output", - str(output_dir), - ] - ) + args = [ + "--new-fork", + "e2e_fork", + "--template-fork", + template_fork, + "--at-timestamp", + "7", + "--output", + str(output_dir), + ] + if not uses_slot_duration_schedule: + args.extend( + [ + "--target-blob-gas-per-block", + "199", + "--blob-base-fee-update-fraction", + "750", + "--min-blob-gasprice", + "2", + "--gas-per-blob", + "1", + "--max-blob-gas-per-block", + "99", + "--blob-schedule-target", + "88", + "--blob-schedule-max", + "77", + ] + ) + + new_fork(args) with (fork_dir / "__init__.py").open("r") as f: source = f.read() - assert '"""' not in source[:20] assert "FORK_CRITERIA: ForkCriteria = ByTimestamp(7)" in source assert template_fork.capitalize() not in source @@ -71,35 +76,60 @@ def test_end_to_end(template_fork: str) -> None: source = f.read() assert "E2E Fork" in source - with (fork_dir / "vm" / "gas.py").open("r") as f: - source = f.read() - - expected = [ - "BLOB_TARGET_GAS_PER_BLOCK: Final[U64] = U64(199)", - "PER_BLOB: Final[U64] = U64(1)", - "BLOB_MIN_GASPRICE: Final[Uint] = Uint(2)", - "BLOB_BASE_FEE_UPDATE_FRACTION: Final[Uint] = Uint(750)", - "BLOB_SCHEDULE_TARGET: Final[U64] = U64(88)", - "BLOB_SCHEDULE_MAX: Final[U64] = U64(77)", - ] - - for needle in expected: - assert needle in source - - with (fork_dir / "fork.py").open("r") as f: - assert "MAX_BLOB_GAS_PER_BLOCK: Final[U64] = U64(99)" in f.read() - - # TODO: Remove this condition once trie.py is removed from all - # forks (i.e. fork-agnostic Trie is ported to pre-amsterdam forks). - template_has_trie = ( - Path("src/ethereum/forks") / template_fork / "trie.py" - ).exists() + if uses_slot_duration_schedule: + with (fork_dir / "slot_timing.py").open("r") as f: + source = f.read() + assert "SLOT_DURATION_SCHEDULE" in source + assert "SlotDurationEntry" in source + else: + with (fork_dir / "vm" / "gas.py").open("r") as f: + source = f.read() + expected = [ + "BLOB_TARGET_GAS_PER_BLOCK: Final[U64] = U64(199)", + "PER_BLOB: Final[U64] = U64(1)", + "BLOB_MIN_GASPRICE: Final[Uint] = Uint(2)", + "BLOB_BASE_FEE_UPDATE_FRACTION: Final[Uint] = Uint(750)", + "BLOB_SCHEDULE_TARGET: Final[U64] = U64(88)", + "BLOB_SCHEDULE_MAX: Final[U64] = U64(77)", + ] + for needle in expected: + assert needle in source + + with (fork_dir / "fork.py").open("r") as f: + assert ( + "MAX_BLOB_GAS_PER_BLOCK: Final[U64] = U64(99)" in f.read() + ) + + template_has_trie = (template_path / "trie.py").exists() if template_has_trie: assert (fork_dir / "trie.py").exists() else: assert not (fork_dir / "trie.py").exists() +def test_schedule_driven_template_rejects_legacy_blob_override() -> None: + """Reject legacy blob knobs instead of silently changing aliases.""" + template_fork = Hardfork.discover()[-1].short_name + template_path = Path("src/ethereum/forks") / template_fork + if not (template_path / "slot_timing.py").exists(): + pytest.skip("latest fork is not schedule-driven") + + with TemporaryDirectory() as base_dir: + with pytest.raises(ValueError, match="SLOT_DURATION_SCHEDULE"): + new_fork( + [ + "--new-fork", + "e2e_fork", + "--template-fork", + template_fork, + "--blob-schedule-target", + "88", + "--output", + str(Path(base_dir) / "ethereum"), + ] + ) + + def has_module_docstring(file_path: Path) -> bool: """Return True if the file starts with a module-level doc-string.""" tree = cst.parse_module(file_path.read_text())