From db7bd660688966c75dd52badf78ed4779a9b4ece Mon Sep 17 00:00:00 2001 From: chugarchugarr Date: Wed, 26 Aug 2026 13:51:47 -0500 Subject: [PATCH 01/24] test(eip-8198): prove repeated slot-duration transitions are schedule-driven --- src/ethereum/forks/amsterdam/slot_timing.py | 157 ++++++++++++++++++ .../amsterdam/eip8198_quick_slots/__init__.py | 1 + .../eip8198_quick_slots/test_slot_timing.py | 110 ++++++++++++ 3 files changed, 268 insertions(+) create mode 100644 src/ethereum/forks/amsterdam/slot_timing.py create mode 100644 tests/amsterdam/eip8198_quick_slots/__init__.py create mode 100644 tests/amsterdam/eip8198_quick_slots/test_slot_timing.py diff --git a/src/ethereum/forks/amsterdam/slot_timing.py b/src/ethereum/forks/amsterdam/slot_timing.py new file mode 100644 index 00000000000..69ebce09e26 --- /dev/null +++ b/src/ethereum/forks/amsterdam/slot_timing.py @@ -0,0 +1,157 @@ +"""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 + +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.""" + + +@final +@dataclass(frozen=True) +class SlotDurationEntry: + """One slot-duration schedule entry.""" + + epoch: U64 + duration_ms: Uint + + +SlotDurationSchedule = Tuple[SlotDurationEntry, ...] + + +@final +@dataclass(frozen=True) +class BlobScheduleParameters: + """Blob parameters coupled to one slot-duration era.""" + + maximum: U64 + target: U64 + update_fraction: Uint + + +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, + 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, + 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_wall_clock_response( + value: Uint, + current_duration_ms: Uint, + base_duration_ms: Uint = BASE_SLOT_DURATION_MS, +) -> Uint: + """Scale an ongoing per-block response to preserve response per second.""" + if current_duration_ms == 0 or base_duration_ms == 0: + raise ValueError("slot durations must be positive") + return Uint(value * current_duration_ms // base_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(previous.maximum * new_duration_ms // old_duration_ms) + target = U64( + (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 = previous.maximum - previous.target + new_headroom = maximum - target + update_fraction = Uint( + previous.update_fraction + * new_headroom + * old_duration_ms + // (old_headroom * new_duration_ms) + ) + return BlobScheduleParameters(maximum, target, update_fraction) 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..6f72cb66873 --- /dev/null +++ b/tests/amsterdam/eip8198_quick_slots/test_slot_timing.py @@ -0,0 +1,110 @@ +"""Tests that EIP-8198 duration changes are schedule-driven.""" + +from ethereum_types.numeric import U64, Uint + +from ethereum.forks.amsterdam.slot_timing import ( + BASE_SLOT_DURATION_MS, + BlobScheduleParameters, + SlotDurationEntry, + get_slot_duration_ms, + get_transition_durations, + scale_blob_schedule, + scale_transition_limit, + scale_wall_clock_response, +) + +HEGOTA_EPOCH = U64(10) +FUTURE_TEST_EPOCH = U64(20) +SCHEDULE_12_10_8 = ( + SlotDurationEntry(HEGOTA_EPOCH, Uint(10000)), + SlotDurationEntry(FUTURE_TEST_EPOCH, Uint(8000)), +) + + +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) + + # The current payload is already seven slots into the 8-second era. + # Comparing execution-payload slots still detects the one-time change. + 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) + + # Once both execution payloads are in the 8-second era, no second scale + # is applied even when there were missed beacon slots between payloads. + 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_base_fee_response_uses_current_over_base_ratio() -> None: + """Ongoing response/sec remains constant across more than one era.""" + unscaled_delta = Uint(1200) + + response_12s = scale_wall_clock_response( + unscaled_delta, BASE_SLOT_DURATION_MS + ) + response_10s = scale_wall_clock_response(unscaled_delta, Uint(10000)) + response_8s = scale_wall_clock_response(unscaled_delta, Uint(8000)) + + assert response_12s == Uint(1200) + assert response_10s == Uint(1000) + assert response_8s == Uint(800) + + # Equal response per millisecond: 1200/12s == 1000/10s == 800/8s. + assert response_12s * Uint(10000) == response_10s * Uint(12000) + assert response_10s * Uint(8000) == response_8s * Uint(10000) + + +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), + ) From 0b3cd7b27921bb6e8fefb33df7ba1d430972b979 Mon Sep 17 00:00:00 2001 From: chugarchugarr Date: Wed, 26 Aug 2026 14:19:27 -0500 Subject: [PATCH 02/24] feat(eip-8198): make slot schedule the EL source of truth --- src/ethereum/forks/amsterdam/slot_timing.py | 65 ++++++++++++++++++++- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/src/ethereum/forks/amsterdam/slot_timing.py b/src/ethereum/forks/amsterdam/slot_timing.py index 69ebce09e26..57085fdffa0 100644 --- a/src/ethereum/forks/amsterdam/slot_timing.py +++ b/src/ethereum/forks/amsterdam/slot_timing.py @@ -16,12 +16,18 @@ 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) @@ -35,6 +41,15 @@ class SlotDurationEntry: SlotDurationSchedule = Tuple[SlotDurationEntry, ...] +# The Amsterdam execution package is activated at the EIP-8198 fork boundary, +# so epoch zero here is relative to the package's active era. The pre-Amsterdam +# parent remains represented by BASE_SLOT_DURATION_MS. Future duration changes +# are additional entries; protocol logic does not change. +SLOT_DURATION_SCHEDULE: Final[SlotDurationSchedule] = ( + SlotDurationEntry(U64(0), Uint(10000)), +) + + @final @dataclass(frozen=True) class BlobScheduleParameters: @@ -45,6 +60,14 @@ class BlobScheduleParameters: 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 @@ -58,7 +81,7 @@ def validate_slot_duration_schedule(schedule: SlotDurationSchedule) -> None: def get_slot_duration_ms( slot_number: U64, - schedule: SlotDurationSchedule, + schedule: SlotDurationSchedule = SLOT_DURATION_SCHEDULE, base_duration_ms: Uint = BASE_SLOT_DURATION_MS, slots_per_epoch: U64 = SLOTS_PER_EPOCH, ) -> Uint: @@ -79,7 +102,7 @@ def get_slot_duration_ms( def get_transition_durations( parent_slot_number: Optional[U64], current_slot_number: U64, - schedule: SlotDurationSchedule, + schedule: SlotDurationSchedule = SLOT_DURATION_SCHEDULE, base_duration_ms: Uint = BASE_SLOT_DURATION_MS, ) -> Tuple[Uint, Uint]: """Return parent/current execution-payload durations. @@ -155,3 +178,41 @@ def scale_blob_schedule( // (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, + ) From ba88ab54270d0af4e8642033d07012018dcee7ae Mon Sep 17 00:00:00 2001 From: chugarchugarr Date: Wed, 26 Aug 2026 14:21:35 -0500 Subject: [PATCH 03/24] feat(eip-8198): drive Amsterdam execution from duration eras --- src/ethereum/forks/amsterdam/fork.py | 653 +++++++-------------------- 1 file changed, 154 insertions(+), 499 deletions(-) diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index bdd5c0c62d0..a257a0c8011 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,91 @@ 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 +360,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 +388,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 +432,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 +461,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 +520,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 +570,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 +585,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 +594,7 @@ 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 +602,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 +615,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 +664,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 +675,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 +683,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 +704,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 +715,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 +726,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 +736,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 +747,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,11 +758,9 @@ 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 - + system_builder_exit_tx_output.return_data + BUILDER_EXIT_REQUEST_TYPE + system_builder_exit_tx_output.return_data ) @@ -1046,30 +770,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 +791,6 @@ def process_transaction( sender = recover_sender(tx) intrinsic = validate_transaction(tx, sender) - ( effective_gas_price, blob_versioned_hashes, @@ -1104,28 +804,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 +851,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 +862,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 +896,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 +917,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 From 13ac3b256f2a785f03bcab58fecaded33718a681 Mon Sep 17 00:00:00 2001 From: chugarchugarr Date: Wed, 26 Aug 2026 14:23:12 -0500 Subject: [PATCH 04/24] feat(eip-8198): make BLOBBASEFEE slot-duration aware --- .../amsterdam/vm/instructions/environment.py | 395 ++---------------- 1 file changed, 30 insertions(+), 365 deletions(-) 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) From 6eabe363da1a11c00e26d8a185a3f2edbde336aa Mon Sep 17 00:00:00 2001 From: chugarchugarr Date: Wed, 26 Aug 2026 14:23:57 -0500 Subject: [PATCH 05/24] test(eip-8198): exercise production paths through 10-to-8 --- .../eip8198_quick_slots/test_slot_timing.py | 71 ++++++++++++++++++- 1 file changed, 69 insertions(+), 2 deletions(-) diff --git a/tests/amsterdam/eip8198_quick_slots/test_slot_timing.py b/tests/amsterdam/eip8198_quick_slots/test_slot_timing.py index 6f72cb66873..1334f8204c1 100644 --- a/tests/amsterdam/eip8198_quick_slots/test_slot_timing.py +++ b/tests/amsterdam/eip8198_quick_slots/test_slot_timing.py @@ -1,11 +1,21 @@ """Tests that EIP-8198 duration changes are schedule-driven.""" +import inspect + from ethereum_types.numeric import U64, Uint +from ethereum.forks.amsterdam import fork as amsterdam_fork +from ethereum.forks.amsterdam.fork import ( + calculate_base_fee_per_gas, + get_max_blob_gas_per_block, +) from ethereum.forks.amsterdam.slot_timing import ( BASE_SLOT_DURATION_MS, + 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, @@ -81,12 +91,33 @@ def test_base_fee_response_uses_current_over_base_ratio() -> None: assert response_12s == Uint(1200) assert response_10s == Uint(1000) assert response_8s == Uint(800) - - # Equal response per millisecond: 1200/12s == 1000/10s == 800/8s. assert response_12s * Uint(10000) == response_10s * Uint(12000) assert response_10s * Uint(8000) == response_8s * Uint(10000) +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( @@ -108,3 +139,39 @@ def test_blob_schedule_derives_repeated_eras_from_same_transition() -> None: 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 From 7972e2f7f9a2915af63c706c21338e9e16504d10 Mon Sep 17 00:00:00 2001 From: chugarchugarr Date: Wed, 26 Aug 2026 14:27:21 -0500 Subject: [PATCH 06/24] fix(eip-8198): satisfy slot-schedule static checks --- src/ethereum/forks/amsterdam/slot_timing.py | 37 ++++++++++----------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/src/ethereum/forks/amsterdam/slot_timing.py b/src/ethereum/forks/amsterdam/slot_timing.py index 57085fdffa0..3d56f9e7ffe 100644 --- a/src/ethereum/forks/amsterdam/slot_timing.py +++ b/src/ethereum/forks/amsterdam/slot_timing.py @@ -1,4 +1,5 @@ -"""Slot-duration schedule helpers for EIP-8198. +""" +Slot-duration schedule helpers for EIP-8198. The execution layer needs two distinct duration ratios: @@ -75,7 +76,9 @@ def validate_slot_duration_schedule(schedule: SlotDurationSchedule) -> None: 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") + raise ValueError( + "slot duration epochs must be strictly increasing" + ) previous_epoch = entry.epoch @@ -105,7 +108,8 @@ def get_transition_durations( schedule: SlotDurationSchedule = SLOT_DURATION_SCHEDULE, base_duration_ms: Uint = BASE_SLOT_DURATION_MS, ) -> Tuple[Uint, Uint]: - """Return parent/current execution-payload durations. + """ + 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 @@ -134,23 +138,13 @@ def scale_transition_limit( return Uint(value * new_duration_ms // old_duration_ms) -def scale_wall_clock_response( - value: Uint, - current_duration_ms: Uint, - base_duration_ms: Uint = BASE_SLOT_DURATION_MS, -) -> Uint: - """Scale an ongoing per-block response to preserve response per second.""" - if current_duration_ms == 0 or base_duration_ms == 0: - raise ValueError("slot durations must be positive") - return Uint(value * current_duration_ms // base_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. + """ + 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 @@ -161,16 +155,21 @@ def scale_blob_schedule( if previous.maximum <= previous.target: raise ValueError("blob maximum must exceed blob target") - maximum = U64(previous.maximum * new_duration_ms // old_duration_ms) + maximum = U64( + Uint(previous.maximum) * new_duration_ms // old_duration_ms + ) target = U64( - (previous.target * new_duration_ms + old_duration_ms // Uint(2)) + ( + 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 = previous.maximum - previous.target - new_headroom = maximum - target + old_headroom = Uint(previous.maximum - previous.target) + new_headroom = Uint(maximum - target) update_fraction = Uint( previous.update_fraction * new_headroom From bbf64d5f645f256ce8d57a14b5b02ea6d33ad1a1 Mon Sep 17 00:00:00 2001 From: chugarchugarr Date: Wed, 26 Aug 2026 14:28:29 -0500 Subject: [PATCH 07/24] test(eip-8198): validate real header path across 10-to-8 --- .../eip8198_quick_slots/test_slot_timing.py | 133 +++++++++++++++--- 1 file changed, 110 insertions(+), 23 deletions(-) diff --git a/tests/amsterdam/eip8198_quick_slots/test_slot_timing.py b/tests/amsterdam/eip8198_quick_slots/test_slot_timing.py index 1334f8204c1..50a835881e8 100644 --- a/tests/amsterdam/eip8198_quick_slots/test_slot_timing.py +++ b/tests/amsterdam/eip8198_quick_slots/test_slot_timing.py @@ -1,16 +1,25 @@ """Tests that EIP-8198 duration changes are schedule-driven.""" import inspect +from dataclasses import replace -from ethereum_types.numeric import U64, Uint +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 ( - BASE_SLOT_DURATION_MS, BLOB_GAS_PER_BLOB, BlobScheduleParameters, SlotDurationEntry, @@ -20,8 +29,8 @@ get_transition_durations, scale_blob_schedule, scale_transition_limit, - scale_wall_clock_response, ) +from ethereum.state import Address, Root HEGOTA_EPOCH = U64(10) FUTURE_TEST_EPOCH = U64(20) @@ -29,6 +38,45 @@ 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: @@ -38,10 +86,18 @@ def test_repeated_duration_changes_are_schedule_only() -> None: 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) + 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: @@ -57,8 +113,6 @@ def test_gas_limit_scales_once_at_each_duration_boundary() -> None: assert (old_ms, new_ms) == (Uint(12000), Uint(10000)) assert gas_limit_10s == Uint(60_000_000) - # The current payload is already seven slots into the 8-second era. - # Comparing execution-payload slots still detects the one-time change. old_ms, new_ms = get_transition_durations( last_10s_payload_slot, U64(first_8s_slot + U64(7)), @@ -68,8 +122,6 @@ def test_gas_limit_scales_once_at_each_duration_boundary() -> None: assert (old_ms, new_ms) == (Uint(10000), Uint(8000)) assert gas_limit_8s == Uint(48_000_000) - # Once both execution payloads are in the 8-second era, no second scale - # is applied even when there were missed beacon slots between payloads. old_ms, new_ms = get_transition_durations( U64(first_8s_slot + U64(7)), U64(first_8s_slot + U64(19)), @@ -78,21 +130,56 @@ def test_gas_limit_scales_once_at_each_duration_boundary() -> None: assert scale_transition_limit(gas_limit_8s, old_ms, new_ms) == gas_limit_8s -def test_base_fee_response_uses_current_over_base_ratio() -> None: - """Ongoing response/sec remains constant across more than one era.""" - unscaled_delta = Uint(1200) +def test_validate_header_handles_missed_payloads_at_second_boundary() -> None: + """ + Validate the production header path across a synthetic 10 -> 8 change. - response_12s = scale_wall_clock_response( - unscaled_delta, BASE_SLOT_DURATION_MS + 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), ) - response_10s = scale_wall_clock_response(unscaled_delta, Uint(10000)) - response_8s = scale_wall_clock_response(unscaled_delta, Uint(8000)) + 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) - assert response_12s == Uint(1200) - assert response_10s == Uint(1000) - assert response_8s == Uint(800) - assert response_12s * Uint(10000) == response_10s * Uint(12000) - assert response_10s * Uint(8000) == response_8s * Uint(10000) + 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: From 223c5574ff344276b11cbd7df50082cee90eb68e Mon Sep 17 00:00:00 2001 From: chugarchugarr Date: Wed, 26 Aug 2026 14:30:24 -0500 Subject: [PATCH 08/24] fix(eip-8198): format Amsterdam duration-era execution path --- src/ethereum/forks/amsterdam/fork.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index a257a0c8011..a93fa060e85 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -271,7 +271,9 @@ 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 the current per-block blob capacity from the duration schedule. + """ return BLOB_GAS_PER_BLOB * get_blob_schedule( slot_number, slot_duration_schedule ).maximum @@ -325,10 +327,12 @@ def calculate_data_fee_for_slot( 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, + return Uint(calculate_total_blob_gas(tx)) * ( + calculate_blob_gas_price_for_slot( + excess_blob_gas, + slot_number, + slot_duration_schedule, + ) ) @@ -340,7 +344,8 @@ def calculate_base_fee_per_gas( gas_limit_reference: Optional[Uint] = None, slot_duration_ms: Optional[Uint] = None, ) -> Uint: - """Calculate base fee while preserving responsiveness per wall-clock time. + """ + 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 @@ -594,7 +599,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( @@ -760,7 +766,8 @@ def process_general_purpose_requests( ) if len(system_builder_exit_tx_output.return_data) > 0: requests_from_execution.append( - BUILDER_EXIT_REQUEST_TYPE + system_builder_exit_tx_output.return_data + BUILDER_EXIT_REQUEST_TYPE + + system_builder_exit_tx_output.return_data ) From 078e628125bfcf4219bb424fa7bc5395798dea24 Mon Sep 17 00:00:00 2001 From: chugarchugarr Date: Wed, 26 Aug 2026 14:32:03 -0500 Subject: [PATCH 09/24] chore: expose EIP-8198 Ruff formatting delta --- .github/workflows/eip8198-format-diff.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .github/workflows/eip8198-format-diff.yml diff --git a/.github/workflows/eip8198-format-diff.yml b/.github/workflows/eip8198-format-diff.yml new file mode 100644 index 00000000000..03d039fcd46 --- /dev/null +++ b/.github/workflows/eip8198-format-diff.yml @@ -0,0 +1,17 @@ +name: EIP-8198 Format Diff + +on: + pull_request: + +jobs: + diff: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - uses: ./.github/actions/setup-uv + - name: Show Ruff formatting delta + run: >- + uv run ruff format --diff + src/ethereum/forks/amsterdam/fork.py + src/ethereum/forks/amsterdam/slot_timing.py + tests/amsterdam/eip8198_quick_slots/test_slot_timing.py From 5aa2ffbdf648bcdcacc9c5cbd55bb401dad8d7a0 Mon Sep 17 00:00:00 2001 From: chugarchugarr Date: Wed, 26 Aug 2026 14:33:04 -0500 Subject: [PATCH 10/24] style(eip-8198): apply Ruff slot timing format --- src/ethereum/forks/amsterdam/slot_timing.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/ethereum/forks/amsterdam/slot_timing.py b/src/ethereum/forks/amsterdam/slot_timing.py index 3d56f9e7ffe..3a20bc8ee3f 100644 --- a/src/ethereum/forks/amsterdam/slot_timing.py +++ b/src/ethereum/forks/amsterdam/slot_timing.py @@ -155,14 +155,9 @@ def scale_blob_schedule( if previous.maximum <= previous.target: raise ValueError("blob maximum must exceed blob target") - maximum = U64( - Uint(previous.maximum) * new_duration_ms // old_duration_ms - ) + maximum = U64(Uint(previous.maximum) * new_duration_ms // old_duration_ms) target = U64( - ( - Uint(previous.target) * new_duration_ms - + old_duration_ms // Uint(2) - ) + (Uint(previous.target) * new_duration_ms + old_duration_ms // Uint(2)) // old_duration_ms ) if maximum <= target: From 9a377cc69bdc44fa1808fce7834f56d0e585dad7 Mon Sep 17 00:00:00 2001 From: chugarchugarr Date: Wed, 26 Aug 2026 14:33:37 -0500 Subject: [PATCH 11/24] style(eip-8198): apply Ruff test format --- .../eip8198_quick_slots/test_slot_timing.py | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/tests/amsterdam/eip8198_quick_slots/test_slot_timing.py b/tests/amsterdam/eip8198_quick_slots/test_slot_timing.py index 50a835881e8..5ec7e2ecb72 100644 --- a/tests/amsterdam/eip8198_quick_slots/test_slot_timing.py +++ b/tests/amsterdam/eip8198_quick_slots/test_slot_timing.py @@ -86,18 +86,12 @@ def test_repeated_duration_changes_are_schedule_only() -> None: 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) + 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: From 098efa35216e63ea48fb971f133d3555febfb708 Mon Sep 17 00:00:00 2001 From: chugarchugarr Date: Wed, 26 Aug 2026 14:35:02 -0500 Subject: [PATCH 12/24] style(eip-8198): apply Ruff Amsterdam format --- src/ethereum/forks/amsterdam/fork.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index a93fa060e85..c4ce81d3865 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -274,9 +274,10 @@ def get_max_blob_gas_per_block( """ 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 + return ( + BLOB_GAS_PER_BLOB + * get_blob_schedule(slot_number, slot_duration_schedule).maximum + ) def calculate_excess_blob_gas_for_slot( From 93f09ded886c7c42a197d62380f60ee0f9d10aa4 Mon Sep 17 00:00:00 2001 From: chugarchugarr Date: Wed, 26 Aug 2026 14:35:16 -0500 Subject: [PATCH 13/24] chore: remove temporary EIP-8198 format probe --- .github/workflows/eip8198-format-diff.yml | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 .github/workflows/eip8198-format-diff.yml diff --git a/.github/workflows/eip8198-format-diff.yml b/.github/workflows/eip8198-format-diff.yml deleted file mode 100644 index 03d039fcd46..00000000000 --- a/.github/workflows/eip8198-format-diff.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: EIP-8198 Format Diff - -on: - pull_request: - -jobs: - diff: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - - uses: ./.github/actions/setup-uv - - name: Show Ruff formatting delta - run: >- - uv run ruff format --diff - src/ethereum/forks/amsterdam/fork.py - src/ethereum/forks/amsterdam/slot_timing.py - tests/amsterdam/eip8198_quick_slots/test_slot_timing.py From d49faac85041f439f0419554aa9f6f1d1a8a8af0 Mon Sep 17 00:00:00 2001 From: chugarchugarr Date: Wed, 26 Aug 2026 14:37:45 -0500 Subject: [PATCH 14/24] chore: run one-shot EIP-8198 gas cleanup --- .github/workflows/eip8198-gas-cleanup.yml | 166 ++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 .github/workflows/eip8198-gas-cleanup.yml diff --git a/.github/workflows/eip8198-gas-cleanup.yml b/.github/workflows/eip8198-gas-cleanup.yml new file mode 100644 index 00000000000..82d8d462dbe --- /dev/null +++ b/.github/workflows/eip8198-gas-cleanup.yml @@ -0,0 +1,166 @@ +name: EIP-8198 Gas Cleanup + +on: + pull_request: + +permissions: + contents: write + +jobs: + cleanup: + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + ref: eip8198-slot-time-agnostic + - uses: ./.github/actions/setup-uv + - name: Remove fixed-duration blob logic + shell: bash + run: | + python - <<'PY' + from pathlib import Path + + path = Path("src/ethereum/forks/amsterdam/vm/gas.py") + text = path.read_text() + + text = text.replace( + "from ethereum.utils.numeric import ceil32, taylor_exponential", + "from ethereum.utils.numeric import ceil32", + ) + + import_anchor = "from .exceptions import OutOfGasError" + slot_import = '''from ..slot_timing import ( + BLOB_BASE_COST as SLOT_BLOB_BASE_COST, + BLOB_GAS_PER_BLOB as SLOT_BLOB_GAS_PER_BLOB, + BLOB_MIN_GASPRICE as SLOT_BLOB_MIN_GASPRICE, + SLOT_DURATION_SCHEDULE, + SlotDurationSchedule, + calculate_blob_gas_price_for_slot, + get_blob_schedule, + ) + ''' + assert import_anchor in text + text = text.replace(import_anchor, slot_import + import_anchor, 1) + + type_anchor = 'if TYPE_CHECKING:\n from . import Evm\n' + assert type_anchor in text + text = text.replace( + type_anchor, + type_anchor + + '\n\n_INITIAL_BLOB_SCHEDULE = get_blob_schedule(U64(0))\n', + 1, + ) + + class_start = text.index(" # Blobs\n", text.index("class GasCosts:")) + class_end = text.index("\n # Block Access Lists", class_start) + blob_constants = ''' # Blobs + # + # 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] = 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 + ) + ''' + text = text[:class_start] + blob_constants + text[class_end:] + + func_start = text.index("def calculate_excess_blob_gas(") + func_end = text.index( + "\n\n@final\n@dataclass\nclass ExecutionGasAllocation", func_start + ) + functions = '''def calculate_excess_blob_gas( + parent_header: Header | PreviousHeader, + current_slot_number: U64 = U64(0), + slot_duration_schedule: SlotDurationSchedule = SLOT_DURATION_SCHEDULE, + ) -> U64: + """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): + 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 < 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, + 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 = 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_total_blob_gas(tx: Transaction) -> U64: + """Calculate the total blob gas for a transaction.""" + if isinstance(tx, BlobTransaction): + return GasCosts.PER_BLOB * U64(len(tx.blob_versioned_hashes)) + return U64(0) + + + def calculate_blob_gas_price( + excess_blob_gas: U64, + slot_number: U64 = U64(0), + 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, + slot_number: U64 = U64(0), + 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, + slot_number, + slot_duration_schedule, + ) + ''' + text = text[:func_start] + functions + text[func_end:] + path.write_text(text) + PY + + uv run ruff format src/ethereum/forks/amsterdam/vm/gas.py + uv run ruff check src/ethereum/forks/amsterdam/vm/gas.py + git rm .github/workflows/eip8198-gas-cleanup.yml + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add src/ethereum/forks/amsterdam/vm/gas.py + git commit -m "refactor(eip-8198): derive blob gas helpers from slot schedule" + git push origin HEAD:eip8198-slot-time-agnostic From 525e5d6c152c6bd07af1dd33bb118846df4d26da Mon Sep 17 00:00:00 2001 From: chugarchugarr Date: Wed, 26 Aug 2026 14:44:08 -0500 Subject: [PATCH 15/24] ci(eip-8198): gate blob cleanup on static and runtime proof --- .github/workflows/eip8198-gas-cleanup.yml | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/.github/workflows/eip8198-gas-cleanup.yml b/.github/workflows/eip8198-gas-cleanup.yml index 82d8d462dbe..e74e3773c76 100644 --- a/.github/workflows/eip8198-gas-cleanup.yml +++ b/.github/workflows/eip8198-gas-cleanup.yml @@ -15,7 +15,7 @@ jobs: with: ref: eip8198-slot-time-agnostic - uses: ./.github/actions/setup-uv - - name: Remove fixed-duration blob logic + - name: Derive blob logic from slot schedule and prove it shell: bash run: | python - <<'PY' @@ -48,7 +48,8 @@ jobs: text = text.replace( type_anchor, type_anchor - + '\n\n_INITIAL_BLOB_SCHEDULE = get_blob_schedule(U64(0))\n', + + '\n\n_INITIAL_SLOT = U64(0)\n' + + '_INITIAL_BLOB_SCHEDULE = get_blob_schedule(_INITIAL_SLOT)\n', 1, ) @@ -80,7 +81,7 @@ jobs: ) functions = '''def calculate_excess_blob_gas( parent_header: Header | PreviousHeader, - current_slot_number: U64 = U64(0), + current_slot_number: U64 = _INITIAL_SLOT, slot_duration_schedule: SlotDurationSchedule = SLOT_DURATION_SCHEDULE, ) -> U64: """Calculate excess blob gas using the current duration era.""" @@ -128,7 +129,7 @@ jobs: def calculate_blob_gas_price( excess_blob_gas: U64, - slot_number: U64 = U64(0), + slot_number: U64 = _INITIAL_SLOT, slot_duration_schedule: SlotDurationSchedule = SLOT_DURATION_SCHEDULE, ) -> Uint: """Calculate the blob gas price for the supplied duration era.""" @@ -142,7 +143,7 @@ jobs: def calculate_data_fee( excess_blob_gas: U64, tx: Transaction, - slot_number: U64 = U64(0), + slot_number: U64 = _INITIAL_SLOT, slot_duration_schedule: SlotDurationSchedule = SLOT_DURATION_SCHEDULE, ) -> Uint: """Calculate the blob data fee for the supplied duration era.""" @@ -156,8 +157,12 @@ jobs: path.write_text(text) PY + uv run ruff check --fix src/ethereum/forks/amsterdam/vm/gas.py uv run ruff format src/ethereum/forks/amsterdam/vm/gas.py uv run ruff check src/ethereum/forks/amsterdam/vm/gas.py + just static + uv run pytest -q tests/amsterdam/eip8198_quick_slots/test_slot_timing.py + git rm .github/workflows/eip8198-gas-cleanup.yml git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" From 6bc3e44fb8a37b39cf85d0a4b7320e5a9f31fcdc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:45:19 +0000 Subject: [PATCH 16/24] refactor(eip-8198): derive blob gas helpers from slot schedule --- .github/workflows/eip8198-gas-cleanup.yml | 171 ---------------------- src/ethereum/forks/amsterdam/vm/gas.py | 168 +++++++++------------ 2 files changed, 73 insertions(+), 266 deletions(-) delete mode 100644 .github/workflows/eip8198-gas-cleanup.yml diff --git a/.github/workflows/eip8198-gas-cleanup.yml b/.github/workflows/eip8198-gas-cleanup.yml deleted file mode 100644 index e74e3773c76..00000000000 --- a/.github/workflows/eip8198-gas-cleanup.yml +++ /dev/null @@ -1,171 +0,0 @@ -name: EIP-8198 Gas Cleanup - -on: - pull_request: - -permissions: - contents: write - -jobs: - cleanup: - if: github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - with: - ref: eip8198-slot-time-agnostic - - uses: ./.github/actions/setup-uv - - name: Derive blob logic from slot schedule and prove it - shell: bash - run: | - python - <<'PY' - from pathlib import Path - - path = Path("src/ethereum/forks/amsterdam/vm/gas.py") - text = path.read_text() - - text = text.replace( - "from ethereum.utils.numeric import ceil32, taylor_exponential", - "from ethereum.utils.numeric import ceil32", - ) - - import_anchor = "from .exceptions import OutOfGasError" - slot_import = '''from ..slot_timing import ( - BLOB_BASE_COST as SLOT_BLOB_BASE_COST, - BLOB_GAS_PER_BLOB as SLOT_BLOB_GAS_PER_BLOB, - BLOB_MIN_GASPRICE as SLOT_BLOB_MIN_GASPRICE, - SLOT_DURATION_SCHEDULE, - SlotDurationSchedule, - calculate_blob_gas_price_for_slot, - get_blob_schedule, - ) - ''' - assert import_anchor in text - text = text.replace(import_anchor, slot_import + import_anchor, 1) - - type_anchor = 'if TYPE_CHECKING:\n from . import Evm\n' - assert type_anchor in text - text = text.replace( - type_anchor, - type_anchor - + '\n\n_INITIAL_SLOT = U64(0)\n' - + '_INITIAL_BLOB_SCHEDULE = get_blob_schedule(_INITIAL_SLOT)\n', - 1, - ) - - class_start = text.index(" # Blobs\n", text.index("class GasCosts:")) - class_end = text.index("\n # Block Access Lists", class_start) - blob_constants = ''' # Blobs - # - # 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] = 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 - ) - ''' - text = text[:class_start] + blob_constants + text[class_end:] - - func_start = text.index("def calculate_excess_blob_gas(") - func_end = text.index( - "\n\n@final\n@dataclass\nclass ExecutionGasAllocation", func_start - ) - functions = '''def calculate_excess_blob_gas( - parent_header: Header | PreviousHeader, - current_slot_number: U64 = _INITIAL_SLOT, - slot_duration_schedule: SlotDurationSchedule = SLOT_DURATION_SCHEDULE, - ) -> U64: - """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): - 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 < 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, - 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 = 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_total_blob_gas(tx: Transaction) -> U64: - """Calculate the total blob gas for a transaction.""" - if isinstance(tx, BlobTransaction): - return GasCosts.PER_BLOB * U64(len(tx.blob_versioned_hashes)) - 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, - 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, - slot_number, - slot_duration_schedule, - ) - ''' - text = text[:func_start] + functions + text[func_end:] - path.write_text(text) - PY - - uv run ruff check --fix src/ethereum/forks/amsterdam/vm/gas.py - uv run ruff format src/ethereum/forks/amsterdam/vm/gas.py - uv run ruff check src/ethereum/forks/amsterdam/vm/gas.py - just static - uv run pytest -q tests/amsterdam/eip8198_quick_slots/test_slot_timing.py - - git rm .github/workflows/eip8198-gas-cleanup.yml - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add src/ethereum/forks/amsterdam/vm/gas.py - git commit -m "refactor(eip-8198): derive blob gas helpers from slot schedule" - git push origin HEAD:eip8198-slot-time-agnostic 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, ) From d940c29ae9a290c9d2f6caddc386d13a9417c582 Mon Sep 17 00:00:00 2001 From: chugarchugarr Date: Wed, 26 Aug 2026 14:47:40 -0500 Subject: [PATCH 17/24] test(eip-8198): close VM blob schedule escape hatch --- .../test_vm_blob_schedule.py | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 tests/amsterdam/eip8198_quick_slots/test_vm_blob_schedule.py 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 From eafa7f4a98fe9522b5edbd3fdc1e5df8755ad215 Mon Sep 17 00:00:00 2001 From: chugarchugarr Date: Wed, 26 Aug 2026 14:56:13 -0500 Subject: [PATCH 18/24] ci(eip-8198): close schedule-aware tooling seam --- .github/workflows/eip8198-tooling-closure.yml | 349 ++++++++++++++++++ 1 file changed, 349 insertions(+) create mode 100644 .github/workflows/eip8198-tooling-closure.yml diff --git a/.github/workflows/eip8198-tooling-closure.yml b/.github/workflows/eip8198-tooling-closure.yml new file mode 100644 index 00000000000..2ed392bceb4 --- /dev/null +++ b/.github/workflows/eip8198-tooling-closure.yml @@ -0,0 +1,349 @@ +name: EIP-8198 Tooling Closure + +on: + pull_request: + +permissions: + contents: write + +jobs: + closure: + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + ref: eip8198-slot-time-agnostic + - uses: ./.github/actions/setup-uv + - name: Make fork tooling schedule-aware and prove it + shell: bash + run: | + python - <<'PY' + from pathlib import Path + + # Fork override matching: a schedule-driven fork exposes its current + # max blob gas through a function rather than a fixed literal. + path = Path("src/ethereum_spec_tools/forks.py") + text = path.read_text() + old = ''' checks = ( + ( + self.max_blob_gas_per_block, + fork_mod, + "MAX_BLOB_GAS_PER_BLOCK", + ), + ''' + new = ''' 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 = ( + ''' + assert old in text + text = text.replace(old, new, 1) + path.write_text(text) + + # ForkBuilder must never silently patch compatibility snapshots on a + # schedule-driven fork; that would produce a clone whose source says + # one thing while runtime follows SLOT_DURATION_SCHEDULE. + path = Path("src/ethereum_spec_tools/new_fork/builder.py") + text = path.read_text() + marker = ''' def modify_target_blob_gas_per_block( + self, blob_target_gas_per_block: U64 + ) -> None: + ''' + helpers = ''' 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" + ) + + ''' + assert marker in text + text = text.replace(marker, helpers + marker, 1) + + replacements = { + ''' """Append a `CodemodArgs` that sets `BLOB_TARGET_GAS_PER_BLOCK`.""" + self.modifiers.append( + ''': ''' """Append a `CodemodArgs` that sets `BLOB_TARGET_GAS_PER_BLOCK`.""" + self._reject_legacy_blob_override("blob_target_gas_per_block") + self.modifiers.append( + ''', + ''' """Append a `CodemodArgs` that sets `PER_BLOB`.""" + self.modifiers.append( + ''': ''' """Append a `CodemodArgs` that sets `PER_BLOB`.""" + self._reject_legacy_blob_override("gas_per_blob") + self.modifiers.append( + ''', + ''' """Append a `CodemodArgs` that sets `BLOB_MIN_GASPRICE`.""" + self.modifiers.append( + ''': ''' """Append a `CodemodArgs` that sets `BLOB_MIN_GASPRICE`.""" + self._reject_legacy_blob_override("blob_min_gasprice") + self.modifiers.append( + ''', + ''' """Append a `CodemodArgs` that sets `BLOB_BASE_FEE_UPDATE_FRACTION`.""" + self.modifiers.append( + ''': ''' """Append a `CodemodArgs` that sets `BLOB_BASE_FEE_UPDATE_FRACTION`.""" + self._reject_legacy_blob_override( + "blob_base_fee_update_fraction" + ) + self.modifiers.append( + ''', + ''' """Append a `CodemodArgs` that sets `MAX_BLOB_GAS_PER_BLOCK`.""" + self.modifiers.append( + ''': ''' """Append a `CodemodArgs` that sets `MAX_BLOB_GAS_PER_BLOCK`.""" + self._reject_legacy_blob_override("max_blob_gas_per_block") + self.modifiers.append( + ''', + ''' """Append a `CodemodArgs` that sets `BLOB_SCHEDULE_TARGET`.""" + self.modifiers.append( + ''': ''' """Append a `CodemodArgs` that sets `BLOB_SCHEDULE_TARGET`.""" + self._reject_legacy_blob_override("blob_schedule_target") + self.modifiers.append( + ''', + ''' """Append a `CodemodArgs` that sets `BLOB_SCHEDULE_MAX`.""" + self.modifiers.append( + ''': ''' """Append a `CodemodArgs` that sets `BLOB_SCHEDULE_MAX`.""" + self._reject_legacy_blob_override("blob_schedule_max") + self.modifiers.append( + ''', + } + for old, new in replacements.items(): + assert old in text, old + text = text.replace(old, new, 1) + path.write_text(text) + + # Fork-cache tests must derive Amsterdam's dynamic max rather than + # requiring a fixed MAX_BLOB_GAS_PER_BLOCK constant. + path = Path("tests/evm_tools/test_fork_cache.py") + text = path.read_text() + anchor = ''' defaults: dict[str, U64 | Uint | None] = { + ''' + derived = ''' 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)) + + ''' + assert anchor in text + text = text.replace(anchor, derived + anchor, 1) + old = ''' "max_blob_gas_per_block": getattr( + fork_mod, + "MAX_BLOB_GAS_PER_BLOCK", + None, + ), + ''' + new = ''' "max_blob_gas_per_block": max_blob_gas_per_block, + ''' + assert old in text + text = text.replace(old, new, 1) + + append_marker = '''def test_fork_cache_reuses_cached_clone_for_identical_changed_request( + ''' + rejection_test = '''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) + + + ''' + assert append_marker in text + text = text.replace(append_marker, rejection_test + append_marker, 1) + path.write_text(text) + + # New-fork's generic blob override test remains on fixed-blob forks. + # Schedule-driven forks are copied without fixed blob overrides and + # explicitly reject those legacy knobs. + path = Path("tests/evm_tools/test_new_fork.py") + text = path.read_text() + start = text.index("@pytest.mark.parametrize(\n") + end = text.index("\ndef has_module_docstring", start) + replacement = '''@pytest.mark.parametrize( + "template_fork", + [ + Hardfork.discover()[-1].short_name, + "osaka", + ], + ids=lambda tf: f"{tf}", + ) + def test_end_to_end(template_fork: str) -> None: + """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() + + 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 + + with (fork_dir / "utils" / "hexadecimal.py").open("r") as f: + source = f.read() + assert "E2E Fork" in source + + 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"), + ] + ) + ''' + text = text[:start] + replacement + text[end:] + path.write_text(text) + + # Clarify that schedule epochs are consensus epochs; epoch zero is + # an always-active sentinel for Amsterdam's initial 10-second era. + path = Path("src/ethereum/forks/amsterdam/slot_timing.py") + text = path.read_text() + old = '''# The Amsterdam execution package is activated at the EIP-8198 fork boundary, + # so epoch zero here is relative to the package's active era. The pre-Amsterdam + # parent remains represented by BASE_SLOT_DURATION_MS. Future duration changes + # are additional entries; protocol logic does not change. + ''' + new = '''# 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. + ''' + assert old in text + path.write_text(text.replace(old, new, 1)) + PY + + uv run ruff check --fix \ + src/ethereum_spec_tools/forks.py \ + src/ethereum_spec_tools/new_fork/builder.py \ + tests/evm_tools/test_fork_cache.py \ + tests/evm_tools/test_new_fork.py \ + src/ethereum/forks/amsterdam/slot_timing.py + uv run ruff format \ + src/ethereum_spec_tools/forks.py \ + src/ethereum_spec_tools/new_fork/builder.py \ + tests/evm_tools/test_fork_cache.py \ + tests/evm_tools/test_new_fork.py \ + src/ethereum/forks/amsterdam/slot_timing.py + just static + just spec-tools + uv run pytest -q tests/amsterdam/eip8198_quick_slots + + git rm .github/workflows/eip8198-tooling-closure.yml + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add \ + src/ethereum_spec_tools/forks.py \ + src/ethereum_spec_tools/new_fork/builder.py \ + tests/evm_tools/test_fork_cache.py \ + tests/evm_tools/test_new_fork.py \ + src/ethereum/forks/amsterdam/slot_timing.py + git commit -m "refactor(eip-8198): make fork tooling schedule-aware" + git push origin HEAD:eip8198-slot-time-agnostic From 9e84693641b71535c81aa0e5e7e250bf3368434b Mon Sep 17 00:00:00 2001 From: chugarchugarr Date: Wed, 26 Aug 2026 14:57:50 -0500 Subject: [PATCH 19/24] ci(eip-8198): add deterministic tooling closure patch --- .github/eip8198_tooling_closure.py | 300 +++++++++++++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 .github/eip8198_tooling_closure.py diff --git a/.github/eip8198_tooling_closure.py b/.github/eip8198_tooling_closure.py new file mode 100644 index 00000000000..3fee1a6696e --- /dev/null +++ b/.github/eip8198_tooling_closure.py @@ -0,0 +1,300 @@ +"""Temporary patch driver for EIP-8198 tooling closure.""" + +from pathlib import Path + + +# Fork override matching: a schedule-driven fork exposes its current max blob +# gas through a function rather than a fixed literal. +path = Path("src/ethereum_spec_tools/forks.py") +text = path.read_text() +old = ''' checks = ( + ( + self.max_blob_gas_per_block, + fork_mod, + "MAX_BLOB_GAS_PER_BLOCK", + ), +''' +new = ''' 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 = ( +''' +assert old in text +path.write_text(text.replace(old, new, 1)) + + +# ForkBuilder must never silently patch compatibility snapshots on a +# schedule-driven fork; that would produce a clone whose source says one thing +# while runtime follows SLOT_DURATION_SCHEDULE. +path = Path("src/ethereum_spec_tools/new_fork/builder.py") +text = path.read_text() +marker = ''' def modify_target_blob_gas_per_block( + self, blob_target_gas_per_block: U64 + ) -> None: +''' +helpers = ''' 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" + ) + +''' +assert marker in text +text = text.replace(marker, helpers + marker, 1) + +replacements = { + ''' """Append a `CodemodArgs` that sets `BLOB_TARGET_GAS_PER_BLOCK`.""" + self.modifiers.append( +''': ''' """Append a `CodemodArgs` that sets `BLOB_TARGET_GAS_PER_BLOCK`.""" + self._reject_legacy_blob_override("blob_target_gas_per_block") + self.modifiers.append( +''', + ''' """Append a `CodemodArgs` that sets `PER_BLOB`.""" + self.modifiers.append( +''': ''' """Append a `CodemodArgs` that sets `PER_BLOB`.""" + self._reject_legacy_blob_override("gas_per_blob") + self.modifiers.append( +''', + ''' """Append a `CodemodArgs` that sets `BLOB_MIN_GASPRICE`.""" + self.modifiers.append( +''': ''' """Append a `CodemodArgs` that sets `BLOB_MIN_GASPRICE`.""" + self._reject_legacy_blob_override("blob_min_gasprice") + self.modifiers.append( +''', + ''' """Append a `CodemodArgs` that sets `BLOB_BASE_FEE_UPDATE_FRACTION`.""" + self.modifiers.append( +''': ''' """Append a `CodemodArgs` that sets `BLOB_BASE_FEE_UPDATE_FRACTION`.""" + self._reject_legacy_blob_override("blob_base_fee_update_fraction") + self.modifiers.append( +''', + ''' """Append a `CodemodArgs` that sets `MAX_BLOB_GAS_PER_BLOCK`.""" + self.modifiers.append( +''': ''' """Append a `CodemodArgs` that sets `MAX_BLOB_GAS_PER_BLOCK`.""" + self._reject_legacy_blob_override("max_blob_gas_per_block") + self.modifiers.append( +''', + ''' """Append a `CodemodArgs` that sets `BLOB_SCHEDULE_TARGET`.""" + self.modifiers.append( +''': ''' """Append a `CodemodArgs` that sets `BLOB_SCHEDULE_TARGET`.""" + self._reject_legacy_blob_override("blob_schedule_target") + self.modifiers.append( +''', + ''' """Append a `CodemodArgs` that sets `BLOB_SCHEDULE_MAX`.""" + self.modifiers.append( +''': ''' """Append a `CodemodArgs` that sets `BLOB_SCHEDULE_MAX`.""" + self._reject_legacy_blob_override("blob_schedule_max") + self.modifiers.append( +''', +} +for before, after in replacements.items(): + assert before in text, before + text = text.replace(before, after, 1) +path.write_text(text) + + +# Fork-cache tests derive Amsterdam's dynamic max rather than demanding a fixed +# MAX_BLOB_GAS_PER_BLOCK constant. +path = Path("tests/evm_tools/test_fork_cache.py") +text = path.read_text() +anchor = ''' defaults: dict[str, U64 | Uint | None] = { +''' +derived = ''' 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)) + +''' +assert anchor in text +text = text.replace(anchor, derived + anchor, 1) +old = ''' "max_blob_gas_per_block": getattr( + fork_mod, + "MAX_BLOB_GAS_PER_BLOCK", + None, + ), +''' +new = ''' "max_blob_gas_per_block": max_blob_gas_per_block, +''' +assert old in text +text = text.replace(old, new, 1) + +append_marker = '''def test_fork_cache_reuses_cached_clone_for_identical_changed_request( +''' +rejection_test = '''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) + + +''' +assert append_marker in text +text = text.replace(append_marker, rejection_test + append_marker, 1) +path.write_text(text) + + +# New-fork's generic blob override test remains on fixed-blob forks. +# Schedule-driven forks are copied without fixed blob overrides and explicitly +# reject those legacy knobs. +path = Path("tests/evm_tools/test_new_fork.py") +text = path.read_text() +start = text.index("@pytest.mark.parametrize(\n") +end = text.index("\ndef has_module_docstring", start) +replacement = '''@pytest.mark.parametrize( + "template_fork", + [ + Hardfork.discover()[-1].short_name, + "osaka", + ], + ids=lambda tf: f"{tf}", +) +def test_end_to_end(template_fork: str) -> None: + """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() + + 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 + + with (fork_dir / "utils" / "hexadecimal.py").open("r") as f: + source = f.read() + assert "E2E Fork" in source + + 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"), + ] + ) +''' +text = text[:start] + replacement + text[end:] +path.write_text(text) + + +# Clarify that schedule epochs are consensus epochs; epoch zero is an +# always-active sentinel for Amsterdam's initial 10-second era. +path = Path("src/ethereum/forks/amsterdam/slot_timing.py") +text = path.read_text() +old = '''# The Amsterdam execution package is activated at the EIP-8198 fork boundary, +# so epoch zero here is relative to the package's active era. The pre-Amsterdam +# parent remains represented by BASE_SLOT_DURATION_MS. Future duration changes +# are additional entries; protocol logic does not change. +''' +new = '''# 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. +''' +assert old in text +path.write_text(text.replace(old, new, 1)) From 9f5a49c8898f8cc0f777559614e46d64e95a005a Mon Sep 17 00:00:00 2001 From: chugarchugarr Date: Wed, 26 Aug 2026 14:58:09 -0500 Subject: [PATCH 20/24] ci(eip-8198): run deterministic tooling closure --- .github/workflows/eip8198-tooling-closure.yml | 307 +----------------- 1 file changed, 4 insertions(+), 303 deletions(-) diff --git a/.github/workflows/eip8198-tooling-closure.yml b/.github/workflows/eip8198-tooling-closure.yml index 2ed392bceb4..48f4c4a2461 100644 --- a/.github/workflows/eip8198-tooling-closure.yml +++ b/.github/workflows/eip8198-tooling-closure.yml @@ -18,308 +18,7 @@ jobs: - name: Make fork tooling schedule-aware and prove it shell: bash run: | - python - <<'PY' - from pathlib import Path - - # Fork override matching: a schedule-driven fork exposes its current - # max blob gas through a function rather than a fixed literal. - path = Path("src/ethereum_spec_tools/forks.py") - text = path.read_text() - old = ''' checks = ( - ( - self.max_blob_gas_per_block, - fork_mod, - "MAX_BLOB_GAS_PER_BLOCK", - ), - ''' - new = ''' 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 = ( - ''' - assert old in text - text = text.replace(old, new, 1) - path.write_text(text) - - # ForkBuilder must never silently patch compatibility snapshots on a - # schedule-driven fork; that would produce a clone whose source says - # one thing while runtime follows SLOT_DURATION_SCHEDULE. - path = Path("src/ethereum_spec_tools/new_fork/builder.py") - text = path.read_text() - marker = ''' def modify_target_blob_gas_per_block( - self, blob_target_gas_per_block: U64 - ) -> None: - ''' - helpers = ''' 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" - ) - - ''' - assert marker in text - text = text.replace(marker, helpers + marker, 1) - - replacements = { - ''' """Append a `CodemodArgs` that sets `BLOB_TARGET_GAS_PER_BLOCK`.""" - self.modifiers.append( - ''': ''' """Append a `CodemodArgs` that sets `BLOB_TARGET_GAS_PER_BLOCK`.""" - self._reject_legacy_blob_override("blob_target_gas_per_block") - self.modifiers.append( - ''', - ''' """Append a `CodemodArgs` that sets `PER_BLOB`.""" - self.modifiers.append( - ''': ''' """Append a `CodemodArgs` that sets `PER_BLOB`.""" - self._reject_legacy_blob_override("gas_per_blob") - self.modifiers.append( - ''', - ''' """Append a `CodemodArgs` that sets `BLOB_MIN_GASPRICE`.""" - self.modifiers.append( - ''': ''' """Append a `CodemodArgs` that sets `BLOB_MIN_GASPRICE`.""" - self._reject_legacy_blob_override("blob_min_gasprice") - self.modifiers.append( - ''', - ''' """Append a `CodemodArgs` that sets `BLOB_BASE_FEE_UPDATE_FRACTION`.""" - self.modifiers.append( - ''': ''' """Append a `CodemodArgs` that sets `BLOB_BASE_FEE_UPDATE_FRACTION`.""" - self._reject_legacy_blob_override( - "blob_base_fee_update_fraction" - ) - self.modifiers.append( - ''', - ''' """Append a `CodemodArgs` that sets `MAX_BLOB_GAS_PER_BLOCK`.""" - self.modifiers.append( - ''': ''' """Append a `CodemodArgs` that sets `MAX_BLOB_GAS_PER_BLOCK`.""" - self._reject_legacy_blob_override("max_blob_gas_per_block") - self.modifiers.append( - ''', - ''' """Append a `CodemodArgs` that sets `BLOB_SCHEDULE_TARGET`.""" - self.modifiers.append( - ''': ''' """Append a `CodemodArgs` that sets `BLOB_SCHEDULE_TARGET`.""" - self._reject_legacy_blob_override("blob_schedule_target") - self.modifiers.append( - ''', - ''' """Append a `CodemodArgs` that sets `BLOB_SCHEDULE_MAX`.""" - self.modifiers.append( - ''': ''' """Append a `CodemodArgs` that sets `BLOB_SCHEDULE_MAX`.""" - self._reject_legacy_blob_override("blob_schedule_max") - self.modifiers.append( - ''', - } - for old, new in replacements.items(): - assert old in text, old - text = text.replace(old, new, 1) - path.write_text(text) - - # Fork-cache tests must derive Amsterdam's dynamic max rather than - # requiring a fixed MAX_BLOB_GAS_PER_BLOCK constant. - path = Path("tests/evm_tools/test_fork_cache.py") - text = path.read_text() - anchor = ''' defaults: dict[str, U64 | Uint | None] = { - ''' - derived = ''' 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)) - - ''' - assert anchor in text - text = text.replace(anchor, derived + anchor, 1) - old = ''' "max_blob_gas_per_block": getattr( - fork_mod, - "MAX_BLOB_GAS_PER_BLOCK", - None, - ), - ''' - new = ''' "max_blob_gas_per_block": max_blob_gas_per_block, - ''' - assert old in text - text = text.replace(old, new, 1) - - append_marker = '''def test_fork_cache_reuses_cached_clone_for_identical_changed_request( - ''' - rejection_test = '''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) - - - ''' - assert append_marker in text - text = text.replace(append_marker, rejection_test + append_marker, 1) - path.write_text(text) - - # New-fork's generic blob override test remains on fixed-blob forks. - # Schedule-driven forks are copied without fixed blob overrides and - # explicitly reject those legacy knobs. - path = Path("tests/evm_tools/test_new_fork.py") - text = path.read_text() - start = text.index("@pytest.mark.parametrize(\n") - end = text.index("\ndef has_module_docstring", start) - replacement = '''@pytest.mark.parametrize( - "template_fork", - [ - Hardfork.discover()[-1].short_name, - "osaka", - ], - ids=lambda tf: f"{tf}", - ) - def test_end_to_end(template_fork: str) -> None: - """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() - - 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 - - with (fork_dir / "utils" / "hexadecimal.py").open("r") as f: - source = f.read() - assert "E2E Fork" in source - - 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"), - ] - ) - ''' - text = text[:start] + replacement + text[end:] - path.write_text(text) - - # Clarify that schedule epochs are consensus epochs; epoch zero is - # an always-active sentinel for Amsterdam's initial 10-second era. - path = Path("src/ethereum/forks/amsterdam/slot_timing.py") - text = path.read_text() - old = '''# The Amsterdam execution package is activated at the EIP-8198 fork boundary, - # so epoch zero here is relative to the package's active era. The pre-Amsterdam - # parent remains represented by BASE_SLOT_DURATION_MS. Future duration changes - # are additional entries; protocol logic does not change. - ''' - new = '''# 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. - ''' - assert old in text - path.write_text(text.replace(old, new, 1)) - PY - + python .github/eip8198_tooling_closure.py uv run ruff check --fix \ src/ethereum_spec_tools/forks.py \ src/ethereum_spec_tools/new_fork/builder.py \ @@ -336,7 +35,9 @@ jobs: just spec-tools uv run pytest -q tests/amsterdam/eip8198_quick_slots - git rm .github/workflows/eip8198-tooling-closure.yml + git rm \ + .github/workflows/eip8198-tooling-closure.yml \ + .github/eip8198_tooling_closure.py git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add \ From fb3a23c5a51b34ee4823e3979ddbe514c3bcdd12 Mon Sep 17 00:00:00 2001 From: chugarchugarr Date: Wed, 26 Aug 2026 14:58:56 -0500 Subject: [PATCH 21/24] ci(eip-8198): format tooling closure before lint --- .github/workflows/eip8198-tooling-closure.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/eip8198-tooling-closure.yml b/.github/workflows/eip8198-tooling-closure.yml index 48f4c4a2461..088c9213d05 100644 --- a/.github/workflows/eip8198-tooling-closure.yml +++ b/.github/workflows/eip8198-tooling-closure.yml @@ -19,13 +19,13 @@ jobs: shell: bash run: | python .github/eip8198_tooling_closure.py - uv run ruff check --fix \ + uv run ruff format \ src/ethereum_spec_tools/forks.py \ src/ethereum_spec_tools/new_fork/builder.py \ tests/evm_tools/test_fork_cache.py \ tests/evm_tools/test_new_fork.py \ src/ethereum/forks/amsterdam/slot_timing.py - uv run ruff format \ + uv run ruff check --fix \ src/ethereum_spec_tools/forks.py \ src/ethereum_spec_tools/new_fork/builder.py \ tests/evm_tools/test_fork_cache.py \ From 92faa352b522136a2217454dfed42f50fb408e31 Mon Sep 17 00:00:00 2001 From: chugarchugarr Date: Wed, 26 Aug 2026 15:01:09 -0500 Subject: [PATCH 22/24] ci(eip-8198): let tooling closure format its bootstrap --- .github/workflows/eip8198-tooling-closure.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/eip8198-tooling-closure.yml b/.github/workflows/eip8198-tooling-closure.yml index 088c9213d05..ba5c94b79bf 100644 --- a/.github/workflows/eip8198-tooling-closure.yml +++ b/.github/workflows/eip8198-tooling-closure.yml @@ -20,12 +20,14 @@ jobs: run: | python .github/eip8198_tooling_closure.py uv run ruff format \ + .github/eip8198_tooling_closure.py \ src/ethereum_spec_tools/forks.py \ src/ethereum_spec_tools/new_fork/builder.py \ tests/evm_tools/test_fork_cache.py \ tests/evm_tools/test_new_fork.py \ src/ethereum/forks/amsterdam/slot_timing.py uv run ruff check --fix \ + .github/eip8198_tooling_closure.py \ src/ethereum_spec_tools/forks.py \ src/ethereum_spec_tools/new_fork/builder.py \ tests/evm_tools/test_fork_cache.py \ From b7ece40f13ddf569a2205faa6ac275da7662fc53 Mon Sep 17 00:00:00 2001 From: chugarchugarr Date: Wed, 26 Aug 2026 15:01:56 -0500 Subject: [PATCH 23/24] ci(eip-8198): prove tooling closure on committed tree --- .github/workflows/eip8198-tooling-closure.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/eip8198-tooling-closure.yml b/.github/workflows/eip8198-tooling-closure.yml index ba5c94b79bf..a566f11278f 100644 --- a/.github/workflows/eip8198-tooling-closure.yml +++ b/.github/workflows/eip8198-tooling-closure.yml @@ -20,26 +20,26 @@ jobs: run: | python .github/eip8198_tooling_closure.py uv run ruff format \ - .github/eip8198_tooling_closure.py \ src/ethereum_spec_tools/forks.py \ src/ethereum_spec_tools/new_fork/builder.py \ tests/evm_tools/test_fork_cache.py \ tests/evm_tools/test_new_fork.py \ src/ethereum/forks/amsterdam/slot_timing.py uv run ruff check --fix \ - .github/eip8198_tooling_closure.py \ src/ethereum_spec_tools/forks.py \ src/ethereum_spec_tools/new_fork/builder.py \ tests/evm_tools/test_fork_cache.py \ tests/evm_tools/test_new_fork.py \ src/ethereum/forks/amsterdam/slot_timing.py - just static - just spec-tools - uv run pytest -q tests/amsterdam/eip8198_quick_slots git rm \ .github/workflows/eip8198-tooling-closure.yml \ .github/eip8198_tooling_closure.py + + just static + just spec-tools + uv run pytest -q tests/amsterdam/eip8198_quick_slots + git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add \ From b9ed6c08d277be4229040dbc94126987b814c1a0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:03:22 +0000 Subject: [PATCH 24/24] refactor(eip-8198): make fork tooling schedule-aware --- .github/eip8198_tooling_closure.py | 300 ------------------ .github/workflows/eip8198-tooling-closure.yml | 52 --- src/ethereum/forks/amsterdam/slot_timing.py | 8 +- src/ethereum_spec_tools/forks.py | 20 +- src/ethereum_spec_tools/new_fork/builder.py | 23 ++ tests/evm_tools/test_fork_cache.py | 22 +- tests/evm_tools/test_new_fork.py | 138 ++++---- 7 files changed, 143 insertions(+), 420 deletions(-) delete mode 100644 .github/eip8198_tooling_closure.py delete mode 100644 .github/workflows/eip8198-tooling-closure.yml diff --git a/.github/eip8198_tooling_closure.py b/.github/eip8198_tooling_closure.py deleted file mode 100644 index 3fee1a6696e..00000000000 --- a/.github/eip8198_tooling_closure.py +++ /dev/null @@ -1,300 +0,0 @@ -"""Temporary patch driver for EIP-8198 tooling closure.""" - -from pathlib import Path - - -# Fork override matching: a schedule-driven fork exposes its current max blob -# gas through a function rather than a fixed literal. -path = Path("src/ethereum_spec_tools/forks.py") -text = path.read_text() -old = ''' checks = ( - ( - self.max_blob_gas_per_block, - fork_mod, - "MAX_BLOB_GAS_PER_BLOCK", - ), -''' -new = ''' 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 = ( -''' -assert old in text -path.write_text(text.replace(old, new, 1)) - - -# ForkBuilder must never silently patch compatibility snapshots on a -# schedule-driven fork; that would produce a clone whose source says one thing -# while runtime follows SLOT_DURATION_SCHEDULE. -path = Path("src/ethereum_spec_tools/new_fork/builder.py") -text = path.read_text() -marker = ''' def modify_target_blob_gas_per_block( - self, blob_target_gas_per_block: U64 - ) -> None: -''' -helpers = ''' 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" - ) - -''' -assert marker in text -text = text.replace(marker, helpers + marker, 1) - -replacements = { - ''' """Append a `CodemodArgs` that sets `BLOB_TARGET_GAS_PER_BLOCK`.""" - self.modifiers.append( -''': ''' """Append a `CodemodArgs` that sets `BLOB_TARGET_GAS_PER_BLOCK`.""" - self._reject_legacy_blob_override("blob_target_gas_per_block") - self.modifiers.append( -''', - ''' """Append a `CodemodArgs` that sets `PER_BLOB`.""" - self.modifiers.append( -''': ''' """Append a `CodemodArgs` that sets `PER_BLOB`.""" - self._reject_legacy_blob_override("gas_per_blob") - self.modifiers.append( -''', - ''' """Append a `CodemodArgs` that sets `BLOB_MIN_GASPRICE`.""" - self.modifiers.append( -''': ''' """Append a `CodemodArgs` that sets `BLOB_MIN_GASPRICE`.""" - self._reject_legacy_blob_override("blob_min_gasprice") - self.modifiers.append( -''', - ''' """Append a `CodemodArgs` that sets `BLOB_BASE_FEE_UPDATE_FRACTION`.""" - self.modifiers.append( -''': ''' """Append a `CodemodArgs` that sets `BLOB_BASE_FEE_UPDATE_FRACTION`.""" - self._reject_legacy_blob_override("blob_base_fee_update_fraction") - self.modifiers.append( -''', - ''' """Append a `CodemodArgs` that sets `MAX_BLOB_GAS_PER_BLOCK`.""" - self.modifiers.append( -''': ''' """Append a `CodemodArgs` that sets `MAX_BLOB_GAS_PER_BLOCK`.""" - self._reject_legacy_blob_override("max_blob_gas_per_block") - self.modifiers.append( -''', - ''' """Append a `CodemodArgs` that sets `BLOB_SCHEDULE_TARGET`.""" - self.modifiers.append( -''': ''' """Append a `CodemodArgs` that sets `BLOB_SCHEDULE_TARGET`.""" - self._reject_legacy_blob_override("blob_schedule_target") - self.modifiers.append( -''', - ''' """Append a `CodemodArgs` that sets `BLOB_SCHEDULE_MAX`.""" - self.modifiers.append( -''': ''' """Append a `CodemodArgs` that sets `BLOB_SCHEDULE_MAX`.""" - self._reject_legacy_blob_override("blob_schedule_max") - self.modifiers.append( -''', -} -for before, after in replacements.items(): - assert before in text, before - text = text.replace(before, after, 1) -path.write_text(text) - - -# Fork-cache tests derive Amsterdam's dynamic max rather than demanding a fixed -# MAX_BLOB_GAS_PER_BLOCK constant. -path = Path("tests/evm_tools/test_fork_cache.py") -text = path.read_text() -anchor = ''' defaults: dict[str, U64 | Uint | None] = { -''' -derived = ''' 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)) - -''' -assert anchor in text -text = text.replace(anchor, derived + anchor, 1) -old = ''' "max_blob_gas_per_block": getattr( - fork_mod, - "MAX_BLOB_GAS_PER_BLOCK", - None, - ), -''' -new = ''' "max_blob_gas_per_block": max_blob_gas_per_block, -''' -assert old in text -text = text.replace(old, new, 1) - -append_marker = '''def test_fork_cache_reuses_cached_clone_for_identical_changed_request( -''' -rejection_test = '''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) - - -''' -assert append_marker in text -text = text.replace(append_marker, rejection_test + append_marker, 1) -path.write_text(text) - - -# New-fork's generic blob override test remains on fixed-blob forks. -# Schedule-driven forks are copied without fixed blob overrides and explicitly -# reject those legacy knobs. -path = Path("tests/evm_tools/test_new_fork.py") -text = path.read_text() -start = text.index("@pytest.mark.parametrize(\n") -end = text.index("\ndef has_module_docstring", start) -replacement = '''@pytest.mark.parametrize( - "template_fork", - [ - Hardfork.discover()[-1].short_name, - "osaka", - ], - ids=lambda tf: f"{tf}", -) -def test_end_to_end(template_fork: str) -> None: - """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() - - 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 - - with (fork_dir / "utils" / "hexadecimal.py").open("r") as f: - source = f.read() - assert "E2E Fork" in source - - 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"), - ] - ) -''' -text = text[:start] + replacement + text[end:] -path.write_text(text) - - -# Clarify that schedule epochs are consensus epochs; epoch zero is an -# always-active sentinel for Amsterdam's initial 10-second era. -path = Path("src/ethereum/forks/amsterdam/slot_timing.py") -text = path.read_text() -old = '''# The Amsterdam execution package is activated at the EIP-8198 fork boundary, -# so epoch zero here is relative to the package's active era. The pre-Amsterdam -# parent remains represented by BASE_SLOT_DURATION_MS. Future duration changes -# are additional entries; protocol logic does not change. -''' -new = '''# 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. -''' -assert old in text -path.write_text(text.replace(old, new, 1)) diff --git a/.github/workflows/eip8198-tooling-closure.yml b/.github/workflows/eip8198-tooling-closure.yml deleted file mode 100644 index a566f11278f..00000000000 --- a/.github/workflows/eip8198-tooling-closure.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: EIP-8198 Tooling Closure - -on: - pull_request: - -permissions: - contents: write - -jobs: - closure: - if: github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - with: - ref: eip8198-slot-time-agnostic - - uses: ./.github/actions/setup-uv - - name: Make fork tooling schedule-aware and prove it - shell: bash - run: | - python .github/eip8198_tooling_closure.py - uv run ruff format \ - src/ethereum_spec_tools/forks.py \ - src/ethereum_spec_tools/new_fork/builder.py \ - tests/evm_tools/test_fork_cache.py \ - tests/evm_tools/test_new_fork.py \ - src/ethereum/forks/amsterdam/slot_timing.py - uv run ruff check --fix \ - src/ethereum_spec_tools/forks.py \ - src/ethereum_spec_tools/new_fork/builder.py \ - tests/evm_tools/test_fork_cache.py \ - tests/evm_tools/test_new_fork.py \ - src/ethereum/forks/amsterdam/slot_timing.py - - git rm \ - .github/workflows/eip8198-tooling-closure.yml \ - .github/eip8198_tooling_closure.py - - just static - just spec-tools - uv run pytest -q tests/amsterdam/eip8198_quick_slots - - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add \ - src/ethereum_spec_tools/forks.py \ - src/ethereum_spec_tools/new_fork/builder.py \ - tests/evm_tools/test_fork_cache.py \ - tests/evm_tools/test_new_fork.py \ - src/ethereum/forks/amsterdam/slot_timing.py - git commit -m "refactor(eip-8198): make fork tooling schedule-aware" - git push origin HEAD:eip8198-slot-time-agnostic diff --git a/src/ethereum/forks/amsterdam/slot_timing.py b/src/ethereum/forks/amsterdam/slot_timing.py index 3a20bc8ee3f..5b6620af0b6 100644 --- a/src/ethereum/forks/amsterdam/slot_timing.py +++ b/src/ethereum/forks/amsterdam/slot_timing.py @@ -42,10 +42,10 @@ class SlotDurationEntry: SlotDurationSchedule = Tuple[SlotDurationEntry, ...] -# The Amsterdam execution package is activated at the EIP-8198 fork boundary, -# so epoch zero here is relative to the package's active era. The pre-Amsterdam -# parent remains represented by BASE_SLOT_DURATION_MS. Future duration changes -# are additional entries; protocol logic does not change. +# 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)), ) 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/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())