diff --git a/packages/testing/src/execution_testing/evm_tools/tests/eip8198_quick_slots/__init__.py b/packages/testing/src/execution_testing/evm_tools/tests/eip8198_quick_slots/__init__.py new file mode 100644 index 00000000000..d823c50cd79 --- /dev/null +++ b/packages/testing/src/execution_testing/evm_tools/tests/eip8198_quick_slots/__init__.py @@ -0,0 +1 @@ +"""Tests for EIP-8198 slot-duration independence.""" diff --git a/packages/testing/src/execution_testing/evm_tools/tests/eip8198_quick_slots/test_additional_duration_era.py b/packages/testing/src/execution_testing/evm_tools/tests/eip8198_quick_slots/test_additional_duration_era.py new file mode 100644 index 00000000000..3974f28effa --- /dev/null +++ b/packages/testing/src/execution_testing/evm_tools/tests/eip8198_quick_slots/test_additional_duration_era.py @@ -0,0 +1,103 @@ +"""Extra synthetic era proving EIP-8198 logic is duration-agnostic.""" + +from ethereum.forks.amsterdam.slot_timing import ( + BlobScheduleParameters, + SlotDurationEntry, + get_blob_schedule, + get_slot_duration_ms, + get_transition_durations, + scale_transition_limit, +) +from ethereum_types.numeric import U64, Uint + +HEGOTA_EPOCH = U64(10) +MID_TEST_EPOCH = U64(15) +FUTURE_TEST_EPOCH = U64(20) +SLOTS_PER_EPOCH = U64(32) + +SCHEDULE_12_10_8_6 = ( + SlotDurationEntry(HEGOTA_EPOCH, Uint(10000)), + SlotDurationEntry(MID_TEST_EPOCH, Uint(8000)), + SlotDurationEntry(FUTURE_TEST_EPOCH, Uint(6000)), +) + + +def test_additional_8s_era_is_schedule_data_only() -> None: + """A 10 -> 8 -> 6 sequence uses the same duration lookup path.""" + first_10s_slot = U64(HEGOTA_EPOCH * SLOTS_PER_EPOCH) + first_8s_slot = U64(MID_TEST_EPOCH * SLOTS_PER_EPOCH) + first_6s_slot = U64(FUTURE_TEST_EPOCH * SLOTS_PER_EPOCH) + + assert get_slot_duration_ms( + U64(first_10s_slot - U64(1)), SCHEDULE_12_10_8_6 + ) == Uint(12000) + assert get_slot_duration_ms(first_10s_slot, SCHEDULE_12_10_8_6) == Uint( + 10000 + ) + assert get_slot_duration_ms(first_8s_slot, SCHEDULE_12_10_8_6) == Uint( + 8000 + ) + assert get_slot_duration_ms(first_6s_slot, SCHEDULE_12_10_8_6) == Uint( + 6000 + ) + + +def test_capacity_scaling_composes_through_8s_era() -> None: + """Gas/sec remains constant through 12 -> 10 -> 8 -> 6.""" + first_10s_slot = U64(HEGOTA_EPOCH * SLOTS_PER_EPOCH) + first_8s_slot = U64(MID_TEST_EPOCH * SLOTS_PER_EPOCH) + first_6s_slot = U64(FUTURE_TEST_EPOCH * SLOTS_PER_EPOCH) + + old_ms, new_ms = get_transition_durations( + None, first_10s_slot, SCHEDULE_12_10_8_6 + ) + gas_10s = scale_transition_limit(Uint(72_000_000), old_ms, new_ms) + assert (old_ms, new_ms, gas_10s) == ( + Uint(12000), + Uint(10000), + Uint(60_000_000), + ) + + old_ms, new_ms = get_transition_durations( + U64(first_8s_slot - U64(7)), + U64(first_8s_slot + U64(7)), + SCHEDULE_12_10_8_6, + ) + gas_8s = scale_transition_limit(gas_10s, old_ms, new_ms) + assert (old_ms, new_ms, gas_8s) == ( + Uint(10000), + Uint(8000), + Uint(48_000_000), + ) + + old_ms, new_ms = get_transition_durations( + U64(first_6s_slot - U64(7)), + U64(first_6s_slot + U64(7)), + SCHEDULE_12_10_8_6, + ) + gas_6s = scale_transition_limit(gas_8s, old_ms, new_ms) + assert (old_ms, new_ms, gas_6s) == ( + Uint(8000), + Uint(6000), + Uint(36_000_000), + ) + + +def test_blob_schedule_composes_through_8s_era() -> None: + """Derive blob parameters through the added era without a new branch.""" + first_8s_slot = U64(MID_TEST_EPOCH * SLOTS_PER_EPOCH) + first_6s_slot = U64(FUTURE_TEST_EPOCH * SLOTS_PER_EPOCH) + + blob_8s = get_blob_schedule(first_8s_slot, SCHEDULE_12_10_8_6) + blob_6s = get_blob_schedule(first_6s_slot, SCHEDULE_12_10_8_6) + + assert blob_8s == BlobScheduleParameters( + maximum=U64(13), + target=U64(10), + update_fraction=Uint(7_511_574), + ) + assert blob_6s == BlobScheduleParameters( + maximum=U64(9), + target=U64(8), + update_fraction=Uint(3_338_477), + ) diff --git a/packages/testing/src/execution_testing/evm_tools/tests/eip8198_quick_slots/test_slot_timing.py b/packages/testing/src/execution_testing/evm_tools/tests/eip8198_quick_slots/test_slot_timing.py new file mode 100644 index 00000000000..88bfcf7d9da --- /dev/null +++ b/packages/testing/src/execution_testing/evm_tools/tests/eip8198_quick_slots/test_slot_timing.py @@ -0,0 +1,263 @@ +"""Tests that EIP-8198 duration changes are schedule-driven.""" + +import inspect +from dataclasses import replace + +import pytest +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, + validate_header, +) +from ethereum.forks.amsterdam.fork_types import Bloom +from ethereum.forks.amsterdam.slot_timing import ( + BLOB_GAS_PER_BLOB, + BlobScheduleParameters, + SlotDurationEntry, + calculate_blob_gas_price_for_slot, + get_blob_schedule, + get_max_blob_gas_per_block, + get_slot_duration_ms, + get_transition_durations, + scale_blob_schedule, + scale_transition_limit, +) +from ethereum.state import Address, Root +from ethereum_rlp import rlp +from ethereum_types.bytes import Bytes, Bytes8, Bytes32 +from ethereum_types.numeric import U64, U256, Uint + +HEGOTA_EPOCH = U64(10) +FUTURE_TEST_EPOCH = U64(20) +SCHEDULE_12_10_6 = ( + SlotDurationEntry(HEGOTA_EPOCH, Uint(10000)), + SlotDurationEntry(FUTURE_TEST_EPOCH, Uint(6000)), +) +ZERO_ROOT = Root(b"\x00" * 32) +ZERO_HASH = Hash32(b"\x00" * 32) + + +def _header( + *, + slot_number: U64, + number: Uint, + gas_limit: Uint, + gas_used: Uint, + base_fee_per_gas: Uint, + timestamp: U256, +) -> Header: + """Build a minimal Amsterdam header for duration-transition tests.""" + return Header( + parent_hash=ZERO_HASH, + ommers_hash=EMPTY_OMMER_HASH, + coinbase=Address(b"\x00" * 20), + state_root=ZERO_ROOT, + transactions_root=ZERO_ROOT, + receipt_root=ZERO_ROOT, + bloom=Bloom(b"\x00" * 256), + difficulty=Uint(0), + number=number, + gas_limit=gas_limit, + gas_used=gas_used, + timestamp=timestamp, + extra_data=Bytes(b""), + prev_randao=Bytes32(b"\x00" * 32), + nonce=Bytes8(b"\x00" * 8), + base_fee_per_gas=base_fee_per_gas, + withdrawals_root=ZERO_ROOT, + blob_gas_used=U64(0), + excess_blob_gas=U64(0), + parent_beacon_block_root=ZERO_ROOT, + requests_hash=ZERO_HASH, + block_access_list_hash=ZERO_HASH, + slot_number=slot_number, + ) + + +def test_repeated_duration_changes_are_schedule_only() -> None: + """A synthetic 10 -> 6 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_6s_slot = U64(FUTURE_TEST_EPOCH * U64(32)) + + assert get_slot_duration_ms(last_12s_slot, SCHEDULE_12_10_6) == Uint(12000) + assert get_slot_duration_ms(first_10s_slot, SCHEDULE_12_10_6) == Uint( + 10000 + ) + assert get_slot_duration_ms(last_10s_slot, SCHEDULE_12_10_6) == Uint(10000) + assert get_slot_duration_ms(first_6s_slot, SCHEDULE_12_10_6) == Uint(6000) + + +def test_gas_limit_scales_once_at_each_duration_boundary() -> None: + """Gas/sec is preserved at both 12 -> 10 and 10 -> 6 transitions.""" + first_10s_slot = U64(HEGOTA_EPOCH * U64(32)) + first_6s_slot = U64(FUTURE_TEST_EPOCH * U64(32)) + last_10s_payload_slot = U64(first_6s_slot - U64(7)) + + old_ms, new_ms = get_transition_durations( + None, first_10s_slot, SCHEDULE_12_10_6 + ) + gas_limit_10s = scale_transition_limit(Uint(72_000_000), old_ms, new_ms) + assert (old_ms, new_ms) == (Uint(12000), Uint(10000)) + assert gas_limit_10s == Uint(60_000_000) + + old_ms, new_ms = get_transition_durations( + last_10s_payload_slot, + U64(first_6s_slot + U64(7)), + SCHEDULE_12_10_6, + ) + gas_limit_6s = scale_transition_limit(gas_limit_10s, old_ms, new_ms) + assert (old_ms, new_ms) == (Uint(10000), Uint(6000)) + assert gas_limit_6s == Uint(36_000_000) + + old_ms, new_ms = get_transition_durations( + U64(first_6s_slot + U64(7)), + U64(first_6s_slot + U64(19)), + SCHEDULE_12_10_6, + ) + assert scale_transition_limit(gas_limit_6s, old_ms, new_ms) == gas_limit_6s + + +def test_validate_header_handles_missed_payloads_at_second_boundary() -> None: + """ + Validate the production header path across a synthetic 10 -> 6 change. + + The parent execution payload is seven slots before the boundary and the + child payload is seven slots after it. The duration transition must still + scale the gas limit exactly once, even though no payload exists at the + scheduled boundary slot. + """ + first_6s_slot = U64(FUTURE_TEST_EPOCH * U64(32)) + parent = _header( + slot_number=U64(first_6s_slot - U64(7)), + number=Uint(100), + gas_limit=Uint(60_000_000), + gas_used=Uint(30_000_000), + base_fee_per_gas=Uint(960), + timestamp=U256(1_000_000), + ) + transition = _header( + slot_number=U64(first_6s_slot + U64(7)), + number=Uint(101), + gas_limit=Uint(36_000_000), + gas_used=Uint(18_000_000), + base_fee_per_gas=Uint(960), + timestamp=U256(1_000_006), + ) + transition = replace( + transition, + parent_hash=keccak256(rlp.encode(parent)), + ) + + validate_header(parent, transition, SCHEDULE_12_10_6) + + unscaled = replace(transition, gas_limit=Uint(60_000_000)) + with pytest.raises(InvalidBlock): + validate_header(parent, unscaled, SCHEDULE_12_10_6) + + ordinary_6s = _header( + slot_number=U64(first_6s_slot + U64(19)), + number=Uint(102), + gas_limit=Uint(36_000_000), + gas_used=Uint(18_000_000), + base_fee_per_gas=Uint(960), + timestamp=U256(1_000_012), + ) + ordinary_6s = replace( + ordinary_6s, + parent_hash=keccak256(rlp.encode(transition)), + ) + validate_header(transition, ordinary_6s, SCHEDULE_12_10_6) + + +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_6s = calculate_base_fee_per_gas( + **common, + slot_duration_ms=Uint(6000), + ) + + assert fee_10s == Uint(1060) + assert fee_6s == Uint(1020) + + +def test_blob_schedule_derives_repeated_eras_from_same_transition() -> None: + """Blob throughput and fee response derive through 12 -> 10 -> 6.""" + 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_6s = scale_blob_schedule(blob_10s, Uint(10000), Uint(6000)) + assert blob_6s == BlobScheduleParameters( + maximum=U64(10), + target=U64(7), + update_fraction=Uint(10_015_432), + ) + + +def test_production_blob_paths_follow_same_schedule() -> None: + """Capacity and blob-fee inputs both follow the synthetic 6s era.""" + first_10s_slot = U64(HEGOTA_EPOCH * U64(32)) + first_6s_slot = U64(FUTURE_TEST_EPOCH * U64(32)) + + blob_10s = get_blob_schedule(first_10s_slot, SCHEDULE_12_10_6) + blob_6s = get_blob_schedule(first_6s_slot, SCHEDULE_12_10_6) + assert blob_10s.maximum == U64(17) + assert blob_6s.maximum == U64(10) + assert blob_10s.target == U64(12) + assert blob_6s.target == U64(7) + # For this exact 10s -> 6s ratio the derived update fraction is unchanged. + # That is valid: the reduced per-block headroom and shorter cadence cancel. + assert blob_6s.update_fraction == blob_10s.update_fraction + + assert get_max_blob_gas_per_block( + first_10s_slot, SCHEDULE_12_10_6 + ) == BLOB_GAS_PER_BLOB * U64(17) + assert get_max_blob_gas_per_block( + first_6s_slot, SCHEDULE_12_10_6 + ) == BLOB_GAS_PER_BLOB * U64(10) + + excess = U64(20_000_000) + fee_10s = calculate_blob_gas_price_for_slot( + excess, first_10s_slot, SCHEDULE_12_10_6 + ) + fee_6s = calculate_blob_gas_price_for_slot( + excess, first_6s_slot, SCHEDULE_12_10_6 + ) + assert fee_10s == Uint(7) + assert fee_6s == Uint(7) + + +def test_amsterdam_has_no_12_to_10_transition_special_case() -> None: + """The production header path contains no previous-duration constant.""" + source = inspect.getsource(amsterdam_fork) + assert "PREVIOUS_SLOT_DURATION_MS" not in source + assert "SLOT_DURATION_MS = Uint(10000)" not in source + assert "get_transition_durations" in source + assert "scale_transition_limit" in source diff --git a/packages/testing/src/execution_testing/evm_tools/tests/eip8198_quick_slots/test_vm_blob_schedule.py b/packages/testing/src/execution_testing/evm_tools/tests/eip8198_quick_slots/test_vm_blob_schedule.py new file mode 100644 index 00000000000..3eb0697ffae --- /dev/null +++ b/packages/testing/src/execution_testing/evm_tools/tests/eip8198_quick_slots/test_vm_blob_schedule.py @@ -0,0 +1,61 @@ +"""Prove VM blob helpers remain slot-duration agnostic.""" + +import inspect + +from ethereum.forks.amsterdam.slot_timing import ( + SlotDurationEntry, + calculate_blob_gas_price_for_slot, +) +from ethereum.forks.amsterdam.vm import gas as vm_gas +from ethereum_types.numeric import U64, Uint + +FUTURE_TEST_EPOCH = U64(20) +SCHEDULE_10_6 = ( + SlotDurationEntry(U64(0), Uint(10000)), + SlotDurationEntry(FUTURE_TEST_EPOCH, Uint(6000)), +) + + +def test_vm_blob_price_uses_slot_schedule_for_future_era() -> None: + """The VM compatibility helper follows the same 10 -> 6 schedule.""" + last_10s_slot = U64(FUTURE_TEST_EPOCH * U64(32) - U64(1)) + first_6s_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_6, + ) + vm_6s = vm_gas.calculate_blob_gas_price( + excess_blob_gas, + first_6s_slot, + SCHEDULE_10_6, + ) + + assert vm_10s == calculate_blob_gas_price_for_slot( + excess_blob_gas, + last_10s_slot, + SCHEDULE_10_6, + ) + assert vm_6s == calculate_blob_gas_price_for_slot( + excess_blob_gas, + first_6s_slot, + SCHEDULE_10_6, + ) + # The derived update fraction happens to be identical for this ratio, + # so equal prices at equal excess are expected rather than a failure. + assert vm_10s == Uint(7) + assert vm_6s == Uint(7) + + +def test_vm_blob_protocol_calculators_do_not_use_initial_snapshot() -> None: + """Initial-era compatibility constants cannot govern later eras.""" + excess_source = inspect.getsource(vm_gas.calculate_excess_blob_gas) + price_source = inspect.getsource(vm_gas.calculate_blob_gas_price) + + assert "get_blob_schedule" in excess_source + assert "BLOB_SCHEDULE_TARGET" not in excess_source + assert "BLOB_SCHEDULE_MAX" not in excess_source + assert "calculate_blob_gas_price_for_slot" in price_source + assert "BLOB_BASE_FEE_UPDATE_FRACTION" not in price_source diff --git a/packages/testing/src/execution_testing/specs/tests/test_transaction.py b/packages/testing/src/execution_testing/specs/tests/test_transaction.py index 95df5252d04..a58bc2c39f0 100644 --- a/packages/testing/src/execution_testing/specs/tests/test_transaction.py +++ b/packages/testing/src/execution_testing/specs/tests/test_transaction.py @@ -6,8 +6,9 @@ import pytest +from execution_testing import TestAddress from execution_testing.fixtures import TransactionFixture -from execution_testing.forks import Fork, Shanghai +from execution_testing.forks import Amsterdam, Fork, Shanghai from execution_testing.test_types import Transaction from ..transaction import TransactionTest @@ -51,3 +52,44 @@ def test_transaction_test_filling( remove_info_metadata(fixture) assert fixture == expected + + +@pytest.mark.parametrize( + "tx,expected_intrinsic_gas", + [ + pytest.param( + Transaction(gas_limit=100_000), + 15_000, + id="non_value_transfer", + ), + pytest.param( + Transaction(gas_limit=100_000, value=1), + 21_000, + id="value_transfer", + ), + pytest.param( + Transaction(gas_limit=100_000, to=TestAddress), + 12_000, + id="self_transfer", + ), + ], +) +def test_amsterdam_transaction_fixture_intrinsic_gas( + tx: Transaction, + expected_intrinsic_gas: int, +) -> None: + """Calculate Amsterdam intrinsic gas from transaction context.""" + fixture = ( + TransactionTest( + tx=tx.with_signature_and_sender(), + fork=Amsterdam, + ) + .generate( + t8n=None, # type: ignore + fixture_format=TransactionFixture, + ) + .fixture + ) + assert isinstance(fixture, TransactionFixture) + result = next(iter(fixture.result.values())) + assert result.intrinsic_gas == expected_intrinsic_gas diff --git a/packages/testing/src/execution_testing/specs/transaction.py b/packages/testing/src/execution_testing/specs/transaction.py index 0bc821e9ff3..142d2647fcc 100644 --- a/packages/testing/src/execution_testing/specs/transaction.py +++ b/packages/testing/src/execution_testing/specs/transaction.py @@ -15,6 +15,7 @@ TransactionFixture, ) from execution_testing.fixtures.transaction import FixtureResult +from execution_testing.recipient_type import RecipientType from execution_testing.test_types import Alloc, Transaction from .base import BaseTest, FillResult, OpMode @@ -62,6 +63,12 @@ def make_transaction_test_fixture( contract_creation=self.tx.to is None, access_list=self.tx.access_list, authorization_list_or_count=self.tx.authorization_list, + sends_value=self.tx.value > 0, + recipient_type=( + RecipientType.SELF + if self.tx.to == self.tx.sender + else RecipientType.CONTRACT + ), ) result = FixtureResult( exception=None, diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index 5c4a10809cc..69a13abc769 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -56,6 +56,14 @@ compute_requests_hash, parse_deposit_requests, ) +from .slot_timing import ( + BASE_SLOT_DURATION_MS, + SLOT_DURATION_SCHEDULE, + SlotDurationSchedule, + get_slot_duration_ms, + get_transition_durations, + scale_transition_limit, +) from .state_tracker import ( BlockState, TransactionState, @@ -111,6 +119,12 @@ 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 = ExecutionGas(Uint(30000000)) SYSTEM_MAX_SSTORES_PER_CALL = Uint(16) """ @@ -134,6 +148,12 @@ 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 @@ -370,57 +390,42 @@ def calculate_base_fee_per_gas( 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. - - 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. - parent_gas_used : - Gas used in the parent block. - parent_base_fee_per_gas : - Base fee per gas of the parent block. - - Returns - ------- - base_fee_per_gas : `Uint` - Base fee per gas for the block. - - """ + """Calculate the base fee while preserving wall-clock response.""" + 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, parent_gas_limit): + if not check_gas_limit(block_gas_limit, gas_limit_reference): raise InvalidBlock if parent_gas_used == parent_gas_target: 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 - base_fee_per_gas_delta = max( - target_fee_gas_delta // BASE_FEE_MAX_CHANGE_DENOMINATOR, + target_fee_gas_delta + * 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 // BASE_FEE_MAX_CHANGE_DENOMINATOR + target_fee_gas_delta + * 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 ) @@ -429,41 +434,48 @@ 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.""" if header.number < Uint(1): raise InvalidBlock - excess_blob_gas = calculate_excess_blob_gas(parent_header) + excess_blob_gas = calculate_excess_blob_gas( + 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): + parent_slot_number = parent_header.slot_number + else: + 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, parent_header.gas_limit, 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 @@ -560,6 +572,7 @@ def check_transaction( tx.blob_versioned_hashes, tx.max_fee_per_blob_gas, block_env.excess_blob_gas, + block_env.slot_number, ) max_gas_fee += Uint(calculate_total_blob_gas(tx)) * Uint( @@ -955,7 +968,11 @@ def update_sender_state( effective_gas_fee = tx_env.gas_limit * tx_env.effective_gas_price if isinstance(tx, BlobTransaction): - blob_gas_fee = calculate_data_fee(block_env.excess_blob_gas, tx) + blob_gas_fee = calculate_data_fee( + block_env.excess_blob_gas, + tx, + block_env.slot_number, + ) else: blob_gas_fee = Uint(0) diff --git a/src/ethereum/forks/amsterdam/slot_timing.py b/src/ethereum/forks/amsterdam/slot_timing.py new file mode 100644 index 00000000000..c4992c40386 --- /dev/null +++ b/src/ethereum/forks/amsterdam/slot_timing.py @@ -0,0 +1,220 @@ +""" +Slot-duration schedule helpers for EIP-8198. + +The execution layer needs two distinct duration ratios: + +* transition ratios compare the current execution payload's duration with + its parent execution payload's duration and apply once at an era boundary; +* wall-clock response ratios compare the current duration with the + pre-schedule base duration and apply to every block in the era. + +Keeping these operations separate prevents a second slot-duration change +from accidentally reusing the one-off transition ratio as an ongoing rate. +""" + +from dataclasses import dataclass +from typing import Final, Optional, Tuple, final + +from ethereum_types.numeric import U64, Uint + +from ethereum.utils.numeric import taylor_exponential + +BASE_SLOT_DURATION_MS: Final[Uint] = Uint(12000) +"""Pre-schedule slot duration, in milliseconds.""" + +SLOTS_PER_EPOCH: Final[U64] = U64(32) +"""Number of slots per epoch; EIP-8198 does not change this value.""" + +BLOB_GAS_PER_BLOB: Final[U64] = U64(2**17) +BLOB_BASE_COST: Final[Uint] = Uint(2**13) +BLOB_MIN_GASPRICE: Final[Uint] = Uint(1) + + +@final +@dataclass(frozen=True) +class SlotDurationEntry: + """One slot-duration schedule entry.""" + + epoch: U64 + duration_ms: Uint + + +SlotDurationSchedule = Tuple[SlotDurationEntry, ...] + + +# The epoch-zero entry is an always-active initial Amsterdam duration when this +# fork package executes. Additional entries use consensus epoch numbers derived +# from Header.slot_number. A future duration change is therefore schedule data; +# protocol logic does not change. +SLOT_DURATION_SCHEDULE: Final[SlotDurationSchedule] = ( + SlotDurationEntry(U64(0), Uint(10000)), +) + + +@final +@dataclass(frozen=True) +class BlobScheduleParameters: + """Blob parameters coupled to one slot-duration era.""" + + maximum: U64 + target: U64 + update_fraction: Uint + + +BASE_BLOB_SCHEDULE: Final[BlobScheduleParameters] = BlobScheduleParameters( + maximum=U64(21), + target=U64(14), + update_fraction=Uint(11_684_671), +) +"""Blob schedule in force before EIP-8198 activates.""" + + +def validate_slot_duration_schedule(schedule: SlotDurationSchedule) -> None: + """Validate ordering and duration constraints of a duration schedule.""" + previous_epoch: Optional[U64] = None + for entry in schedule: + if entry.duration_ms == 0 or entry.duration_ms % Uint(1000) != 0: + raise ValueError("slot duration must be a positive whole second") + if previous_epoch is not None and entry.epoch <= previous_epoch: + raise ValueError( + "slot duration epochs must be strictly increasing" + ) + previous_epoch = entry.epoch + + +def get_slot_duration_ms( + slot_number: U64, + schedule: SlotDurationSchedule = SLOT_DURATION_SCHEDULE, + base_duration_ms: Uint = BASE_SLOT_DURATION_MS, + slots_per_epoch: U64 = SLOTS_PER_EPOCH, +) -> Uint: + """Return the scheduled duration in effect for ``slot_number``.""" + validate_slot_duration_schedule(schedule) + if base_duration_ms == 0 or slots_per_epoch == 0: + raise ValueError("base duration and slots per epoch must be positive") + + epoch = slot_number // slots_per_epoch + duration_ms = base_duration_ms + for entry in schedule: + if epoch < entry.epoch: + break + duration_ms = entry.duration_ms + return duration_ms + + +def get_transition_durations( + parent_slot_number: Optional[U64], + current_slot_number: U64, + schedule: SlotDurationSchedule = SLOT_DURATION_SCHEDULE, + base_duration_ms: Uint = BASE_SLOT_DURATION_MS, +) -> Tuple[Uint, Uint]: + """ + Return parent/current execution-payload durations. + + ``None`` represents the legacy parent at the first EIP-8198 execution + payload. Future transitions use the parent execution payload's actual + slot, so missed slots and withheld payloads cannot suppress the change. + """ + new_duration_ms = get_slot_duration_ms( + current_slot_number, schedule, base_duration_ms + ) + if parent_slot_number is None: + old_duration_ms = base_duration_ms + else: + old_duration_ms = get_slot_duration_ms( + parent_slot_number, schedule, base_duration_ms + ) + return old_duration_ms, new_duration_ms + + +def scale_transition_limit( + value: Uint, old_duration_ms: Uint, new_duration_ms: Uint +) -> Uint: + """Scale a per-block capacity once when the duration era changes.""" + if old_duration_ms == 0 or new_duration_ms == 0: + raise ValueError("slot durations must be positive") + if old_duration_ms == new_duration_ms: + return value + return Uint(value * new_duration_ms // old_duration_ms) + + +def scale_blob_schedule( + previous: BlobScheduleParameters, + old_duration_ms: Uint, + new_duration_ms: Uint, +) -> BlobScheduleParameters: + """ + Derive blob parameters for the next duration era. + + Maximum blob count truncates down, target blob count rounds to nearest, + and the update fraction preserves maximum sustained blob-fee response + per unit of wall-clock time. + """ + if old_duration_ms == 0 or new_duration_ms == 0: + raise ValueError("slot durations must be positive") + if previous.maximum <= previous.target: + raise ValueError("blob maximum must exceed blob target") + + maximum = U64(Uint(previous.maximum) * new_duration_ms // old_duration_ms) + target = U64( + (Uint(previous.target) * new_duration_ms + old_duration_ms // Uint(2)) + // old_duration_ms + ) + if maximum <= target: + raise ValueError("scaled blob maximum must exceed scaled target") + + old_headroom = Uint(previous.maximum - previous.target) + new_headroom = Uint(maximum - target) + update_fraction = Uint( + previous.update_fraction + * new_headroom + * old_duration_ms + // (old_headroom * new_duration_ms) + ) + return BlobScheduleParameters(maximum, target, update_fraction) + + +def get_blob_schedule( + slot_number: U64, + schedule: SlotDurationSchedule = SLOT_DURATION_SCHEDULE, + base_duration_ms: Uint = BASE_SLOT_DURATION_MS, + base_blob_schedule: BlobScheduleParameters = BASE_BLOB_SCHEDULE, +) -> BlobScheduleParameters: + """Return blob parameters derived through every active duration era.""" + validate_slot_duration_schedule(schedule) + current_epoch = slot_number // SLOTS_PER_EPOCH + duration_ms = base_duration_ms + blob_schedule = base_blob_schedule + + for entry in schedule: + if current_epoch < entry.epoch: + break + if entry.duration_ms != duration_ms: + blob_schedule = scale_blob_schedule( + blob_schedule, duration_ms, entry.duration_ms + ) + duration_ms = entry.duration_ms + + return blob_schedule + + +def get_max_blob_gas_per_block( + slot_number: U64, + schedule: SlotDurationSchedule = SLOT_DURATION_SCHEDULE, +) -> U64: + """Return blob gas capacity for the active duration era.""" + return BLOB_GAS_PER_BLOB * get_blob_schedule(slot_number, schedule).maximum + + +def calculate_blob_gas_price_for_slot( + excess_blob_gas: U64, + slot_number: U64, + schedule: SlotDurationSchedule = SLOT_DURATION_SCHEDULE, +) -> Uint: + """Calculate the blob gas price using the current duration era.""" + blob_schedule = get_blob_schedule(slot_number, schedule) + return taylor_exponential( + BLOB_MIN_GASPRICE, + Uint(excess_blob_gas), + blob_schedule.update_fraction, + ) diff --git a/src/ethereum/forks/amsterdam/vm/gas.py b/src/ethereum/forks/amsterdam/vm/gas.py index d599a1b654e..9cef4ba33b8 100644 --- a/src/ethereum/forks/amsterdam/vm/gas.py +++ b/src/ethereum/forks/amsterdam/vm/gas.py @@ -19,7 +19,7 @@ from ethereum.exceptions import GasUsedExceedsLimitError 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 ..exceptions import ( @@ -32,6 +32,13 @@ StateGasPerByte, VersionedHash, ) +from ..slot_timing import ( + SLOT_DURATION_SCHEDULE, + SlotDurationSchedule, + calculate_blob_gas_price_for_slot, + get_blob_schedule, + get_max_blob_gas_per_block, +) from ..transactions import ( TX_MAX_GAS_LIMIT, BlobTransaction, @@ -44,6 +51,9 @@ from . import BlockEnvironment, BlockOutput, Evm +_INITIAL_SLOT = U64(0) + + # These may be patched at runtime by a future gas repricing utility to # fast-iterate on state-byte costs. class StateGasCosts: @@ -140,13 +150,22 @@ 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(14) + BLOB_SCHEDULE_TARGET: Final[U64] = U64(12) 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(21) + BLOB_SCHEDULE_MAX: Final[U64] = U64(17) BLOB_MIN_GASPRICE: Final[Uint] = Uint(1) - BLOB_BASE_FEE_UPDATE_FRACTION: Final[Uint] = Uint(11684671) + BLOB_BASE_FEE_UPDATE_FRACTION: Final[Uint] = Uint(10015432) # Block Access Lists BLOCK_ACCESS_LIST_ITEM: Final[ExecutionGas] = ExecutionGas(Uint(2000)) @@ -862,52 +881,43 @@ def init_code_cost(init_code_length: Uint) -> ExecutionGas: 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. - - """ - # Defaults for a parent without blob gas fields. + """Calculate excess blob gas using the current slot-duration era.""" excess_blob_gas = U64(0) blob_gas_used = U64(0) base_fee_per_gas = Uint(0) if isinstance(parent_header, (Header, PreviousHeader)): - # Read them from any parent that carries the fields, so - # accumulated excess blob gas survives a fork transition. 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: @@ -931,47 +941,30 @@ def calculate_total_blob_gas(tx: Transaction) -> U64: 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, +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, ) @@ -979,33 +972,18 @@ def check_max_fee_per_blob_gas( blob_versioned_hashes: Tuple[VersionedHash, ...], max_fee_per_blob_gas: U256, excess_blob_gas: U64, + slot_number: U64 = _INITIAL_SLOT, + slot_duration_schedule: SlotDurationSchedule = SLOT_DURATION_SCHEDULE, ) -> None: - """ - Check that a transaction carrying blobs pays at least the blob gas - price. - - A transaction without blobs pays no blob fee, so its fee cap is not - checked. - - Parameters - ---------- - blob_versioned_hashes : - The transaction's blob versioned hashes. - max_fee_per_blob_gas : - The transaction's fee cap per unit of blob gas. - excess_blob_gas : - The block's excess blob gas. - - Raises - ------ - InsufficientMaxFeePerBlobGasError : - If the fee cap does not cover the blob gas price. - - """ + """Check that a blob transaction covers the active-era blob price.""" if not blob_versioned_hashes: return - blob_gas_price = calculate_blob_gas_price(excess_blob_gas) + blob_gas_price = calculate_blob_gas_price( + excess_blob_gas, + slot_number, + slot_duration_schedule, + ) if Uint(max_fee_per_blob_gas) < blob_gas_price: raise InsufficientMaxFeePerBlobGasError( "insufficient max fee per blob gas" @@ -1053,7 +1031,10 @@ def check_block_gas_capacity( 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 + ) if min(TX_MAX_GAS_LIMIT, tx_gas) > execution_gas_available: raise GasUsedExceedsLimitError("execution gas used exceeds limit") diff --git a/src/ethereum/forks/amsterdam/vm/instructions/environment.py b/src/ethereum/forks/amsterdam/vm/instructions/environment.py index 582c36c1c58..3403286c371 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/environment.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/environment.py @@ -603,7 +603,10 @@ def blob_base_fee(evm: Evm) -> None: charge_gas(evm, GasCosts.OPCODE_BLOBBASEFEE) # OPERATION - blob_base_fee = calculate_blob_gas_price(evm.block_env.excess_blob_gas) + blob_base_fee = calculate_blob_gas_price( + evm.block_env.excess_blob_gas, + evm.block_env.slot_number, + ) push(evm.stack, U256(blob_base_fee)) # PROGRAM COUNTER diff --git a/tests/amsterdam/eip7928_block_level_access_lists/spec.py b/tests/amsterdam/eip7928_block_level_access_lists/spec.py index b51e8d412ec..eb781125d47 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/spec.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/spec.py @@ -18,26 +18,4 @@ class ReferenceSpec: class Spec: - """Constants and parameters from EIP-7928.""" - - # RLP encoding is used for block access list data structures - BAL_ENCODING_FORMAT: str = "RLP" - - # Maximum limits for block access list data structures - TARGET_MAX_GAS_LIMIT = 600_000_000 - MAX_TXS: int = 30_000 - MAX_SLOTS: int = 300_000 - MAX_ACCOUNTS: int = 300_000 - # TODO: Use this as a function of the current fork. - MAX_CODE_SIZE: int = 24_576 # 24 KiB - - # Type size constants - ADDRESS_SIZE: int = 20 # Ethereum address size in bytes - STORAGE_KEY_SIZE: int = 32 # Storage slot key size in bytes - STORAGE_VALUE_SIZE: int = 32 # Storage value size in bytes - HASH_SIZE: int = 32 # Hash size in bytes - - # Numeric type limits - MAX_TX_INDEX: int = 2**32 - 1 # uint32 max value - MAX_BALANCE: int = 2**128 - 1 # uint128 max value - MAX_NONCE: int = 2**64 - 1 # uint64 max value + """Constants from EIP-7928.""" diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py index 74aa1a089a9..d1b1c4e7e4c 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py @@ -43,7 +43,9 @@ ) from execution_testing import Macros as Om -from .spec import Spec, ref_spec_7928 +from tests.frontier.eip2681_limit_account_nonce.spec import Spec as Spec2681 + +from .spec import ref_spec_7928 from .test_block_access_lists_eip4788 import SYSTEM_ADDRESS REFERENCE_SPEC_GIT_PATH = ref_spec_7928.git_path @@ -3576,8 +3578,8 @@ def test_bal_create_early_failure( @pytest.mark.parametrize( "factory_nonce", [ - pytest.param(Spec.MAX_NONCE, id="nonce_at_max"), - pytest.param(Spec.MAX_NONCE - 1, id="nonce_below_max"), + pytest.param(Spec2681.max_nonce, id="nonce_at_max"), + pytest.param(Spec2681.max_nonce - 1, id="nonce_below_max"), ], ) def test_bal_create_nonce_overflow( @@ -3635,15 +3637,15 @@ def test_bal_create_nonce_overflow( target_expectation: BalAccountExpectation | None target_post: Account | None - if factory_nonce == Spec.MAX_NONCE: + if factory_nonce == Spec2681.max_nonce: create_result = 0 factory_nonce_changes = [] target_expectation = None target_post = Account.NONEXISTENT - elif factory_nonce == Spec.MAX_NONCE - 1: + elif factory_nonce == Spec2681.max_nonce - 1: create_result = 1 factory_nonce_changes = [ - BalNonceChange(block_access_index=1, post_nonce=Spec.MAX_NONCE) + BalNonceChange(block_access_index=1, post_nonce=Spec2681.max_nonce) ] target_expectation = BalAccountExpectation( nonce_changes=[BalNonceChange(block_access_index=1, post_nonce=1)], @@ -3662,7 +3664,7 @@ def test_bal_create_nonce_overflow( # At the boundary the nonce is unchanged; one below, it is # incremented into it. Both arms end at the maximum. factory: Account( - nonce=Spec.MAX_NONCE, storage={0x00: create_result} + nonce=Spec2681.max_nonce, storage={0x00: create_result} ), target: target_post, }, diff --git a/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_cross_frame_refund.py b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_cross_frame_refund.py new file mode 100644 index 00000000000..f772c9d8bc4 --- /dev/null +++ b/tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_cross_frame_refund.py @@ -0,0 +1,516 @@ +""" +Test where a state gas refund lands when it is credited in a +different frame than the spilled charge it undoes. + +A state charge spilled from `gas_left` can be refunded in a child +frame. The credit lands in the child's reservoir and merges upward as +reservoir, so `gas_left` is never repaid mid-transaction. The parked +credit still funds later state creation at full price and returns to +the sender at settlement, so cross-frame placement opens no discount +on state and costs the sender nothing at the transaction boundary. + +The merge-time repayment proposed in [ethereum/EIPs#12265] +(https://github.com/ethereum/EIPs/pull/12265) moves the credit back +to `gas_left` when a successful child merges. The placement pins here +flip under it, while the settlement pins are placement-independent +and must hold unchanged. + +Tests for [EIP-8037: State Creation Gas Cost Increase] +(https://eips.ethereum.org/EIPS/eip-8037). +""" + +import pytest +from execution_testing import ( + Account, + Alloc, + Bytecode, + Fork, + Op, + Opcode, + StateTestFiller, + Transaction, + TransactionReceipt, +) + +from .spec import ref_spec_8037 + +REFERENCE_SPEC_GIT_PATH = ref_spec_8037.git_path +REFERENCE_SPEC_VERSION = ref_spec_8037.version + +SLOT_X = 1 +SLOT_Y = 2 +SLOT_MARKER = 3 +SLOT_RESULT = 4 +SLOT_INCREASED = 5 + + +def window_cost_excess(result_sstore: Opcode = Op.SSTORE) -> Bytecode: + """ + Return code storing the first window's cost over the second's. + + Memory holds `g0`, `g1` and `g2` at 0, 32 and 64. The stored + value is `(g0 - g1) - (g1 - g2)`, the first window's cost minus + the second's, computed modulo 2**256. `result_sstore` lets + gas-settlement tests carry metadata on the storing opcode. + """ + return result_sstore( + SLOT_RESULT, + Op.SUB( + Op.ADD(Op.MLOAD(0), Op.MLOAD(64)), + Op.ADD(Op.MLOAD(32), Op.MLOAD(32)), + ), + ) + + +@pytest.mark.valid_from("EIP8037") +def test_cross_frame_refund_parks_in_reservoir( + state_test: StateTestFiller, + pre: Alloc, +) -> None: + """ + Test a cross-frame refund credits the reservoir, not `gas_left`. + + The frame sets a slot with the reservoir empty, spilling the state + charge from `gas_left`. A delegated child clears the slot and the + credit lands in the reservoir, where it stays through the merge: + `gas_left` is lower after the clearing call than before it, and + the clearing window costs the same as a no-op window. + """ + clearer = pre.deploy_contract(code=Op.SSTORE(SLOT_X, 0)) + + call_window = Op.POP(Op.DELEGATECALL(address=clearer)) + code = ( + # Warm the clearer and the slot while it is still zero, and + # pre-expand the measurement memory, so the two measured + # windows below are byte-identical and cost-identical. + call_window + + Op.MSTORE(64, 0) + + Op.SSTORE(SLOT_X, 1) + + Op.MSTORE(0, Op.GAS) + + call_window + + Op.MSTORE(32, Op.GAS) + + call_window + + Op.MSTORE(64, Op.GAS) + + Op.SSTORE(SLOT_INCREASED, Op.GT(Op.MLOAD(32), Op.MLOAD(0))) + + window_cost_excess() + # Every other expected slot is zero, so the marker is what + # distinguishes the pinned run from a reverted one. + + Op.SSTORE(SLOT_MARKER, 1) + ) + contract = pre.deploy_contract(code=code) + + tx = Transaction( + to=contract, + state_gas_reservoir=0, + sender=pre.fund_eoa(), + ) + + # Under the merge-time repayment of ethereum/EIPs#12265 the + # clearing window repays the spill: the increase flag becomes 1 + # and the window excess wraps to minus the slot's state cost. + post = { + contract: Account( + storage={ + SLOT_X: 0, + SLOT_MARKER: 1, + SLOT_INCREASED: 0, + SLOT_RESULT: 0, + } + ) + } + state_test(pre=pre, post=post, tx=tx) + + +@pytest.mark.valid_from("EIP8037") +def test_parked_credit_returns_at_settlement( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Test the parked credit refunds the sender at settlement. + + The frame's spilled set is cleared by a child, parking the credit + in the reservoir. Settlement sums `gas_left` and the reservoir, so + the spilled charge and the parked credit cancel and the receipt + carries no state term at all. The receipt is placement-independent + and holds unchanged under ethereum/EIPs#12265. + """ + intrinsic_cost = fork.transaction_intrinsic_cost_calculator()() + sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) + + clearer_code = Op.SSTORE.with_metadata( + key_warm=True, + original_value=0, + current_value=1, + new_value=0, + )(SLOT_X, 0) + clearer = pre.deploy_contract(code=clearer_code) + + # A budget covering the child's SSTORE stipend sentry through the + # clear, so the child succeeds and returns the sentry unspent. + child_budget = ( + fork.call_value_stipend() + 1 + clearer_code.execution_cost(fork) + ) + code = Op.SSTORE( + SLOT_X, + 1, + key_warm=False, + original_value=0, + current_value=0, + new_value=1, + ) + Op.POP( + Op.DELEGATECALL(gas=child_budget, address=clearer, address_warm=False) + ) + contract = pre.deploy_contract(code=code) + + before_refund = ( + intrinsic_cost + + code.execution_cost(fork) + + clearer_code.execution_cost(fork) + ) + # Clearing the slot back to its original value also refunds the + # write cost through the classic refund counter at settlement. + restore_refund = clearer_code.refund(fork) - sstore_state_gas + expected_gas_used = before_refund - min( + before_refund // fork.max_refund_quotient(), restore_refund + ) + # The post-refund usage must clear the calldata floor, or the + # floor masks a lost or doubled credit. + assert expected_gas_used > fork.transaction_data_floor_cost_calculator()( + data=b"" + ) + + tx = Transaction( + to=contract, + state_gas_reservoir=0, + sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt( + cumulative_gas_used=expected_gas_used + ), + ) + + post = {contract: Account(storage={SLOT_X: 0})} + state_test(pre=pre, post=post, tx=tx) + + +@pytest.mark.valid_from("EIP8037") +def test_parked_credit_funds_state_at_full_price( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Test the parked credit funds a later creation at full price. + + After the cross-frame clear parks the credit, a fresh set draws + its state charge from the reservoir: `gas_left` drops by only the + execution premium across the set window. The receipt still bills + both surviving slots at the full state price, so routing a refund + through another frame buys no discount on state that persists. + """ + intrinsic_cost = fork.transaction_intrinsic_cost_calculator()() + sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) + + clearer_code = Op.SSTORE.with_metadata( + key_warm=True, + original_value=0, + current_value=1, + new_value=0, + )(SLOT_X, 0) + clearer = pre.deploy_contract(code=clearer_code) + child_budget = ( + fork.call_value_stipend() + 1 + clearer_code.execution_cost(fork) + ) + + fresh_set = Op.SSTORE.with_metadata( + key_warm=False, + original_value=0, + current_value=0, + new_value=1, + ) + window_1 = fresh_set(SLOT_Y, 1) + window_2 = Op.SSTORE.with_metadata( + key_warm=True, + original_value=0, + current_value=1, + new_value=1, + )(SLOT_Y, 1) + # The windows are byte-identical, so the excess is the fresh set's + # execution premium plus whatever its state charge takes from + # `gas_left`. The parked credit covers the state charge, leaving + # the execution premium alone. Under ethereum/EIPs#12265 the merge + # drains the credit into `gas_left` first, so the set spills and + # the excess grows by the slot's state cost. + execution_premium = window_1.execution_cost( + fork + ) - window_2.execution_cost(fork) + + code = ( + Op.MSTORE(64, 0, new_memory_size=96, old_memory_size=0) + + fresh_set(SLOT_X, 1) + + Op.POP( + Op.DELEGATECALL( + gas=child_budget, address=clearer, address_warm=False + ) + ) + + Op.MSTORE(0, Op.GAS) + + window_1 + + Op.MSTORE(32, Op.GAS) + + window_2 + + Op.MSTORE(64, Op.GAS) + + window_cost_excess(result_sstore=fresh_set) + ) + contract = pre.deploy_contract(code=code) + + # Slot Y and the result slot survive, each fully priced. The + # cleared slot cancels out of the settlement sum. + before_refund = ( + intrinsic_cost + + code.execution_cost(fork) + + clearer_code.execution_cost(fork) + + 2 * sstore_state_gas + ) + restore_refund = clearer_code.refund(fork) - sstore_state_gas + expected_gas_used = before_refund - min( + before_refund // fork.max_refund_quotient(), restore_refund + ) + + tx = Transaction( + to=contract, + state_gas_reservoir=0, + sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt( + cumulative_gas_used=expected_gas_used + ), + ) + + post = { + contract: Account( + storage={ + SLOT_X: 0, + SLOT_Y: 1, + SLOT_RESULT: execution_premium, + } + ) + } + state_test(pre=pre, post=post, tx=tx) + + +@pytest.mark.valid_from("EIP8037") +def test_parked_credit_cannot_fund_execution( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Test the parked credit cannot fund execution work. + + The gas limit covers the transaction only up to the clearing + child's merge plus a sliver. An execution tail worth less than the + parked credit follows, and the transaction halts anyway: the + credit sits in the reservoir, spendable on state creation alone. + Under ethereum/EIPs#12265 the merge repays the spill and the same + budget completes. + """ + intrinsic_cost = fork.transaction_intrinsic_cost_calculator()() + sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) + + clearer_code = Op.SSTORE.with_metadata( + key_warm=True, + original_value=0, + current_value=1, + new_value=0, + )(SLOT_X, 0) + clearer = pre.deploy_contract(code=clearer_code) + + fresh_set = Op.SSTORE.with_metadata( + key_warm=False, + original_value=0, + current_value=0, + new_value=1, + ) + head = ( + fresh_set(SLOT_MARKER, 1) + + fresh_set(SLOT_X, 1) + + Op.POP( + Op.DELEGATECALL(gas=Op.GAS, address=clearer, address_warm=False) + ) + ) + # TODO: The tail spends a set amount of execution gas; a JUMPDEST + # run is the most future-proof inline way until a fork util exists. + tail_ops = min( + sstore_state_gas // Op.JUMPDEST.gas_cost(fork), + fork.max_code_size() - len(head), + ) + tail = Op.JUMPDEST * tail_ops + code = head + tail + contract = pre.deploy_contract(code=code) + + # A sliver covering the child's SSTORE stipend sentry through the + # one-in-64 withholding. It survives the merge unspent. + sliver = ( + fork.call_value_stipend() + 1 + clearer_code.execution_cost(fork) + ) * 64 // 63 + 1 + tail_cost = tail.gas_cost(fork) + # The tail must overrun the sliver yet fit inside the parked + # credit, or the halt stops demonstrating the credit cannot buy + # execution. + assert sliver < tail_cost <= sstore_state_gas + + gas_limit = ( + intrinsic_cost + + head.gas_cost(fork) + + clearer_code.gas_cost(fork) + + sliver + ) + + tx = Transaction( + to=contract, + gas_limit=gas_limit, + sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt(cumulative_gas_used=gas_limit), + ) + + post = {contract: Account(storage={SLOT_MARKER: 0, SLOT_X: 0})} + state_test(pre=pre, post=post, tx=tx) + + +@pytest.mark.parametrize("child_ending", ["stop", "revert", "invalid"]) +@pytest.mark.valid_from("EIP8037") +def test_child_clear_repays_own_spill_first( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + child_ending: str, +) -> None: + """ + Test the cross-slot LIFO split of a cross-frame refund in a child. + + The parent spills two fresh sets; a delegated child spills a set + of its own, then clears both parent slots. The first credit repays + the child's borrow, the second parks in the reservoir, and a + failing child discards the parked credit with its rollback. + """ + intrinsic_cost = fork.transaction_intrinsic_cost_calculator()() + sstore_state_gas = Op.SSTORE(new_value=1).state_cost(fork) + + fresh_set = Op.SSTORE.with_metadata( + key_warm=False, + original_value=0, + current_value=0, + new_value=1, + ) + warm_clear = Op.SSTORE.with_metadata( + key_warm=True, + original_value=0, + current_value=1, + new_value=0, + ) + + child_body = ( + fresh_set(SLOT_MARKER, 1) + + warm_clear(SLOT_X, 0) + + warm_clear(SLOT_Y, 0) + ) + if child_ending == "stop": + child_code = child_body + Op.STOP + elif child_ending == "revert": + child_code = child_body + Op.REVERT(0, 0) + elif child_ending == "invalid": + child_code = child_body + Op.INVALID + else: + raise ValueError(f"unhandled child ending: {child_ending}") + child = pre.deploy_contract(code=child_code) + + # A budget covering the child's SSTORE stipend sentry through its + # own spilled set and both clears. + child_budget = fork.call_value_stipend() + 1 + child_code.gas_cost(fork) + + call_window = Op.POP( + Op.DELEGATECALL(gas=child_budget, address=child, address_warm=False) + ) + code = ( + fresh_set(SLOT_X, 1) + + fresh_set(SLOT_Y, 1) + + Op.MSTORE(32, 0, new_memory_size=64, old_memory_size=0) + + Op.MSTORE(0, Op.GAS) + + call_window + + Op.MSTORE(32, Op.GAS) + + fresh_set(SLOT_RESULT, Op.SUB(Op.MLOAD(0), Op.MLOAD(32))) + ) + contract = pre.deploy_contract(code=code) + + if child_ending == "stop": + child_consumed = child_code.execution_cost(fork) + elif child_ending == "revert": + child_consumed = child_code.execution_cost(fork) + elif child_ending == "invalid": + child_consumed = child_budget + else: + raise ValueError(f"unhandled child ending: {child_ending}") + # Gas measured between the two reads: the first stamp's store, the + # call window, the child's consumption, and the second read itself. + window_cost = ( + Op.MSTORE(0, Op.GAS).gas_cost(fork) + + call_window.execution_cost(fork) + + child_consumed + ) + + parent_exec = code.execution_cost(fork) + if child_ending == "stop": + # The child's slot and the result slot survive; the child's + # borrow was repaid by the first clear's credit, so only the + # parked second credit cancels a parent spill at settlement. + before_refund = ( + intrinsic_cost + + parent_exec + + child_code.execution_cost(fork) + + 2 * sstore_state_gas + ) + restore_refund = 2 * (warm_clear.refund(fork) - sstore_state_gas) + expected_gas_used = before_refund - min( + before_refund // fork.max_refund_quotient(), restore_refund + ) + elif child_ending == "revert": + expected_gas_used = ( + intrinsic_cost + + parent_exec + + child_code.execution_cost(fork) + + 3 * sstore_state_gas + ) + elif child_ending == "invalid": + expected_gas_used = ( + intrinsic_cost + parent_exec + child_budget + 3 * sstore_state_gas + ) + else: + raise ValueError(f"unhandled child ending: {child_ending}") + + tx = Transaction( + to=contract, + state_gas_reservoir=0, + sender=pre.fund_eoa(), + expected_receipt=TransactionReceipt( + cumulative_gas_used=expected_gas_used + ), + ) + + if child_ending == "stop": + storage = { + SLOT_X: 0, + SLOT_Y: 0, + SLOT_MARKER: 1, + SLOT_RESULT: window_cost, + } + elif child_ending in ("revert", "invalid"): + storage = { + SLOT_X: 1, + SLOT_Y: 1, + SLOT_MARKER: 0, + SLOT_RESULT: window_cost, + } + else: + raise ValueError(f"unhandled child ending: {child_ending}") + + post = {contract: Account(storage=storage)} + state_test(pre=pre, post=post, tx=tx) diff --git a/tests/frontier/validation/test_transaction_rlp.py b/tests/frontier/validation/test_transaction_rlp.py new file mode 100644 index 00000000000..e20dfa44ec2 --- /dev/null +++ b/tests/frontier/validation/test_transaction_rlp.py @@ -0,0 +1,538 @@ +"""Tests for RLP-level validity of type-0 transaction encodings.""" + +from typing import Dict, List, Mapping + +import pytest +from execution_testing import ( + Alloc, + Bytes, + Fork, + Transaction, + TransactionException, + TransactionTestFiller, +) + +pytestmark = [ + pytest.mark.valid_from("Frontier"), + pytest.mark.inclusion_test, +] + +LEGACY_TX_TESTS = ( + "https://github.com/ethereum/tests/blob/" + "c67e485ff8b5be9abc8ad15345ec21aa22e290d9/src/TransactionTestsFiller" +) + + +def encode_header(length: int, offset: int) -> bytes: + """Encode an RLP header for a payload of the given length.""" + if length < 56: + return bytes([offset + length]) + size = length.to_bytes((length.bit_length() + 7) // 8, "big") + return bytes([offset + 55 + len(size)]) + size + + +def rlp_bytes(payload: bytes) -> bytes: + """Encode a byte string.""" + if len(payload) == 1 and payload[0] < 0x80: + return payload + return encode_header(len(payload), 0x80) + payload + + +def rlp_list(items: List[bytes]) -> bytes: + """Encode a list of already-encoded items.""" + payload = b"".join(items) + return encode_header(len(payload), 0xC0) + payload + + +def int_payload(value: int) -> bytes: + """Return the canonical RLP payload of an integer.""" + if value == 0: + return b"" + return value.to_bytes((value.bit_length() + 7) // 8, "big") + + +def tx_fields(tx: Transaction) -> Dict[str, bytes]: + """ + Decompose a signed transaction into its canonical RLP payload per + field, using the framework's own field order and values. + """ + return { + name: bytes(el) if isinstance(el, bytes) else int_payload(int(el)) + for name, el in zip( + tx.get_rlp_fields(), + tx.to_list(signing=False), + strict=True, + ) + } + + +def signed_tx( + pre: Alloc, + fork: Fork, + nonce: int = 0, + data: bytes = b"", + funded: bool = True, +) -> Transaction: + """ + Build and sign the base type-0 transaction. + + Pass `funded=False` when the transaction is only used as an + encoding source and never sent, so that the execute mode does not + defer funding of a sender that never sends a transaction. + """ + return Transaction( + sender=pre.fund_eoa() if funded else pre.fund_eoa(amount=0), + to=pre.fund_eoa(amount=0), + nonce=nonce, + gas_price=10, + gas_limit=30_000, + value=1, + data=data, + protected=fork.supports_protected_txs(), + ).with_signature_and_sender() + + +def signed_tx_fields( + pre: Alloc, fork: Fork, nonce: int = 0, data: bytes = b"" +) -> Dict[str, bytes]: + """Build a signed type-0 transaction and decompose it.""" + return tx_fields( + signed_tx(pre, fork, nonce=nonce, data=data, funded=False) + ) + + +def encode_tx( + fields: Mapping[str, bytes], override: Mapping[str, bytes] | None = None +) -> bytes: + """ + Encode the transaction fields, allowing per-field pre-encoded + replacements. + """ + override = override or {} + return rlp_list( + [override.get(name, rlp_bytes(fields[name])) for name in fields] + ) + + +def invalid_tx( + pre: Alloc, + rlp: bytes, + error: TransactionException | list[TransactionException], +) -> Transaction: + """Return a transaction whose serialization is the given raw bytes.""" + tx = Transaction( + sender=pre.fund_eoa(amount=0), + to=0, + gas_price=10, + gas_limit=30_000, + error=error, + ) + tx.rlp_override = Bytes(rlp) + return tx + + +def test_valid_reencoded_transaction( + transaction_test: TransactionTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Verify the local field encoder reproduces the framework encoding + byte for byte, anchoring the malformation tests to a valid baseline. + """ + tx = signed_tx(pre, fork) + fields = tx_fields(tx) + assert encode_tx(fields) == bytes(tx.rlp()) + transaction_test(pre=pre, tx=tx) + + +@pytest.mark.ported_from( + [ + f"{LEGACY_TX_TESTS}/ttWrongRLP/RLPNonceWithFirstZerosCopier.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/RLPgasPriceWithFirstZerosCopier.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/RLPgasLimitWithFirstZerosCopier.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/RLPValueWithFirstZerosCopier.json", + f"{LEGACY_TX_TESTS}/ttRSValue/TransactionWithRvaluePrefixed00Filler.json", + f"{LEGACY_TX_TESTS}/ttRSValue/TransactionWithSvaluePrefixed00Filler.json", + f"{LEGACY_TX_TESTS}/ttRSValue/RightVRSTestVPrefixedBy0Filler.json", + f"{LEGACY_TX_TESTS}/ttNonce/TransactionWithLeadingZerosNonceFiller.json", + f"{LEGACY_TX_TESTS}/ttNonce/TransactionWithZerosBigIntFiller.json", + f"{LEGACY_TX_TESTS}/ttGasPrice/" + "TransactionWithLeadingZerosGasPriceFiller.json", + f"{LEGACY_TX_TESTS}/ttGasLimit/" + "TransactionWithLeadingZerosGasLimitFiller.json", + f"{LEGACY_TX_TESTS}/ttValue/TransactionWithLeadingZerosValueFiller.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/TRANSCT_gasLimit_Prefixed0000Copier.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/TRANSCT_rvalue_Prefixed0000Copier.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/TRANSCT_svalue_Prefixed0000Copier.json", + ], +) +@pytest.mark.exception_test +@pytest.mark.parametrize( + "field, error", + [ + ("nonce", TransactionException.RLP_LEADING_ZEROS_NONCE), + ("gas_price", TransactionException.RLP_LEADING_ZEROS_GASPRICE), + ("gas_limit", TransactionException.RLP_LEADING_ZEROS_GASLIMIT), + ("value", TransactionException.RLP_LEADING_ZEROS_VALUE), + ("v", TransactionException.RLP_LEADING_ZEROS_V), + ("r", TransactionException.RLP_LEADING_ZEROS_R), + ("s", TransactionException.RLP_LEADING_ZEROS_S), + ], +) +def test_field_leading_zeros( + transaction_test: TransactionTestFiller, + pre: Alloc, + fork: Fork, + field: str, + error: TransactionException, +) -> None: + """ + Prefix one integer field's payload with a zero byte; the + non-canonical encoding must be rejected. The base nonce is zero, so + its variant is the classic zero-encoded-as-0x00 case. + """ + fields = signed_tx_fields(pre, fork) + corrupted = rlp_bytes(b"\x00" + fields[field]) + rlp = encode_tx(fields, {field: corrupted}) + transaction_test(pre=pre, tx=invalid_tx(pre, rlp, error)) + + +@pytest.mark.ported_from( + [ + f"{LEGACY_TX_TESTS}/ttWrongRLP/RLPIncorrectByteEncoding00Copier.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/RLPIncorrectByteEncoding01Copier.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/RLPIncorrectByteEncoding127Copier.json", + ], +) +@pytest.mark.exception_test +def test_non_canonical_single_byte( + transaction_test: TransactionTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Encode the single-byte nonce payload behind a one-byte string + header (0x8101) instead of as the byte itself; the non-canonical + encoding must be rejected. + """ + fields = signed_tx_fields(pre, fork, nonce=1) + payload = fields["nonce"] + assert len(payload) == 1 and payload[0] < 0x80 + rlp = encode_tx(fields, {"nonce": b"\x81" + payload}) + transaction_test( + pre=pre, + tx=invalid_tx( + pre, rlp, TransactionException.RLP_LEADING_ZEROS_NONCE_SIZE + ), + ) + + +@pytest.mark.ported_from( + [ + f"{LEGACY_TX_TESTS}/ttWrongRLP/" + "RLPArrayLengthWithFirstZerosCopier.json", + ], +) +@pytest.mark.exception_test +def test_data_size_leading_zeros( + transaction_test: TransactionTestFiller, + pre: Alloc, + fork: Fork, +) -> None: + """ + Encode the size of the data field's long-form string header with a + leading zero byte; the non-canonical encoding must be rejected. + """ + fields = signed_tx_fields(pre, fork, data=b"\xff" * 64) + payload = fields["data"] + # The corruption assumes the long-form string header. + assert len(payload) >= 56 + size = len(payload).to_bytes(2, "big") + assert size[0] == 0 + corrupted = bytes([0x80 + 55 + len(size)]) + size + payload + rlp = encode_tx(fields, {"data": corrupted}) + transaction_test( + pre=pre, + tx=invalid_tx( + pre, rlp, TransactionException.RLP_LEADING_ZEROS_DATA_SIZE + ), + ) + + +@pytest.mark.ported_from( + [ + f"{LEGACY_TX_TESTS}/ttNonce/TransactionWithNonceOverflowFiller.json", + f"{LEGACY_TX_TESTS}/ttValue/TransactionWithHighValueOverflowFiller.json", + f"{LEGACY_TX_TESTS}/ttRSValue/TransactionWithRvalueOverflowFiller.json", + f"{LEGACY_TX_TESTS}/ttRSValue/TransactionWithSvalueOverflowFiller.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/TRANSCT_rvalue_TooLargeCopier.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/TRANSCT_svalue_TooLargeCopier.json", + ], +) +@pytest.mark.exception_test +@pytest.mark.parametrize( + "field, error", + [ + ("nonce", TransactionException.RLP_INVALID_NONCE), + ("value", TransactionException.VALUE_OVERFLOW), + ("r", TransactionException.RLP_INVALID_SIGNATURE_R), + ("s", TransactionException.RLP_INVALID_SIGNATURE_S), + ], +) +def test_field_overflow( + transaction_test: TransactionTestFiller, + pre: Alloc, + fork: Fork, + field: str, + error: TransactionException, +) -> None: + """ + Encode one integer field as a 33-byte value (2**256), exceeding the + 256-bit width of the field. The spec decodes the nonce as a 256-bit + scalar as well; its 64-bit bound is a validation rule (EIP-2681), + not a decoding one, and clients that store the nonce in 64 bits + reject the oversized encoding all the same. + + The gas limit and gas price are unbounded scalars in the spec, so + oversized values there are not a decoding error and are rejected + only in block context. The signature v is also a bounded 256-bit + field, but has no field-specific decoding exception, so its + oversized encoding is not covered here. + """ + fields = signed_tx_fields(pre, fork) + corrupted = rlp_bytes(int_payload(2**256)) + rlp = encode_tx(fields, {field: corrupted}) + transaction_test(pre=pre, tx=invalid_tx(pre, rlp, error)) + + +@pytest.mark.ported_from( + [ + f"{LEGACY_TX_TESTS}/ttWrongRLP/RLPAddressWrongSizeCopier.json", + f"{LEGACY_TX_TESTS}/ttAddress/AddressLessThan20Filler.json", + f"{LEGACY_TX_TESTS}/ttAddress/AddressMoreThan20PrefixedBy0Filler.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/RLPAddressWithFirstZerosCopier.json", + f"{LEGACY_TX_TESTS}/ttAddress/AddressMoreThan20Filler.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/TRANSCT_to_Prefixed0000Copier.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/TRANSCT_to_TooLargeCopier.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/TRANSCT_to_TooShortCopier.json", + ], +) +@pytest.mark.exception_test +@pytest.mark.parametrize( + "size, error", + [ + (19, TransactionException.ADDRESS_TOO_SHORT), + (21, TransactionException.ADDRESS_TOO_LONG), + ], +) +def test_to_address_size( + transaction_test: TransactionTestFiller, + pre: Alloc, + fork: Fork, + size: int, + error: TransactionException, +) -> None: + """ + Encode the to field with a truncated 19-byte address or a 21-byte + address made of a zero byte prefixing a valid address. + """ + fields = signed_tx_fields(pre, fork) + if size < 20: + fields["to"] = fields["to"][:size] + else: + fields["to"] = fields["to"].rjust(size, b"\x00") + rlp = encode_tx(fields) + transaction_test(pre=pre, tx=invalid_tx(pre, rlp, error)) + + +@pytest.mark.ported_from( + [ + f"{LEGACY_TX_TESTS}/ttWrongRLP/" + "RLPElementIsListWhenItShouldntBeCopier.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/" + "RLPElementIsListWhenItShouldntBe2Copier.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/TRANSCT_rvalue_GivenAsListCopier.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/TRANSCT_svalue_GivenAsListCopier.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/TRANSCT_data_GivenAsListCopier.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/TRANSCT_gasLimit_GivenAsListCopier.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/TRANSCT_to_GivenAsListCopier.json", + ], +) +@pytest.mark.exception_test +@pytest.mark.parametrize( + "field, error", + [ + ("nonce", TransactionException.RLP_INVALID_NONCE), + ("gas_limit", TransactionException.RLP_INVALID_GASLIMIT), + ("to", TransactionException.RLP_INVALID_TO), + ("value", TransactionException.RLP_INVALID_VALUE), + ("data", TransactionException.RLP_INVALID_DATA), + ("r", TransactionException.RLP_INVALID_SIGNATURE_R), + ("s", TransactionException.RLP_INVALID_SIGNATURE_S), + ], +) +def test_field_as_list( + transaction_test: TransactionTestFiller, + pre: Alloc, + fork: Fork, + field: str, + error: TransactionException, +) -> None: + """ + Encode one field as an RLP list instead of a byte string. + + The gas price and v fields are equally rejected but have no + field-specific decoding exception, so they are not covered. + """ + fields = signed_tx_fields(pre, fork) + corrupted = rlp_list([rlp_bytes(fields[field])]) + rlp = encode_tx(fields, {field: corrupted}) + transaction_test(pre=pre, tx=invalid_tx(pre, rlp, error)) + + +@pytest.mark.ported_from( + [ + f"{LEGACY_TX_TESTS}/ttWrongRLP/RLPExtraRandomByteAtTheEndCopier.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/TRANSCT_HeaderLargerThanRLP_0Copier.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/TRANSCT_HeaderGivenAsArray_0Copier.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/RLPListLengthWithFirstZerosCopier.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/aMaliciousRLPCopier.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/RLPTransactionGivenAsArrayCopier.json", + ], +) +@pytest.mark.exception_test +@pytest.mark.parametrize( + "mutation, error", + [ + ("truncated", TransactionException.RLP_ERROR_EOF), + ("extra_byte", TransactionException.RLP_ERROR_SIZE), + ("too_few_elements", TransactionException.RLP_TOO_FEW_ELEMENTS), + ("too_many_elements", TransactionException.RLP_TOO_MANY_ELEMENTS), + ("header_declares_more", TransactionException.RLP_ERROR_EOF), + ( + "header_declares_less", + [ + TransactionException.RLP_ERROR_EOF, + TransactionException.RLP_ERROR_SIZE, + ], + ), + ( + "tx_as_byte_string", + [ + TransactionException.RLP_INVALID_HEADER, + TransactionException.TYPE_NOT_SUPPORTED, + ], + ), + ( + "list_size_leading_zeros", + TransactionException.RLP_ERROR_SIZE_LEADING_ZEROS, + ), + ], +) +def test_invalid_structure( + transaction_test: TransactionTestFiller, + pre: Alloc, + fork: Fork, + mutation: str, + error: TransactionException | list[TransactionException], +) -> None: + """ + Corrupt the RLP structure of the whole transaction. + + A list header that declares less than the actual payload leaves + both a truncated final field and a trailing byte at the top level, + so clients report it as either an EOF or a size error. + + Encoding the transaction as a byte string is a plain RLP error + before EIP-2718; afterwards the string is read as a typed + transaction whose type byte is unsupported. + """ + fields = signed_tx_fields(pre, fork) + items = [rlp_bytes(fields[name]) for name in fields] + payload = b"".join(items) + # The header mutations assume the long-form list header. + assert len(payload) >= 56 + good = encode_tx(fields) + if mutation == "truncated": + rlp = good[:-1] + elif mutation == "extra_byte": + rlp = good + b"\x00" + elif mutation == "too_few_elements": + rlp = rlp_list(items[:-1]) + elif mutation == "too_many_elements": + rlp = rlp_list(items + [rlp_bytes(b"")]) + elif mutation == "header_declares_more": + rlp = encode_header(len(payload) + 1, 0xC0) + payload + elif mutation == "header_declares_less": + rlp = encode_header(len(payload) - 1, 0xC0) + payload + elif mutation == "tx_as_byte_string": + rlp = encode_header(len(payload), 0x80) + payload + elif mutation == "list_size_leading_zeros": + size = len(payload).to_bytes(2, "big") + assert size[0] == 0 + rlp = bytes([0xC0 + 55 + len(size)]) + size + payload + transaction_test(pre=pre, tx=invalid_tx(pre, rlp, error)) + + +@pytest.mark.ported_from( + [ + f"{LEGACY_TX_TESTS}/ttRSValue/TransactionWithRvalue0Filler.json", + f"{LEGACY_TX_TESTS}/ttRSValue/TransactionWithSvalue0Filler.json", + f"{LEGACY_TX_TESTS}/ttVValue/V_wrongvalue_ffFiller.json", + f"{LEGACY_TX_TESTS}/ttWrongRLP/tr201506052141PYTHONCopier.json", + ], +) +@pytest.mark.exception_test +@pytest.mark.parametrize( + "field, payload, error", + [ + ("r", b"", TransactionException.INVALID_SIGNATURE_VRS), + ("s", b"", TransactionException.INVALID_SIGNATURE_VRS), + ( + "v", + b"", + [ + TransactionException.INVALID_SIGNATURE_VRS, + TransactionException.INVALID_CHAINID, + ], + ), + ( + "v", + b"\x1d", + [ + TransactionException.INVALID_SIGNATURE_VRS, + TransactionException.INVALID_CHAINID, + ], + ), + ( + "v", + b"\xff", + [ + TransactionException.INVALID_SIGNATURE_VRS, + TransactionException.INVALID_CHAINID, + ], + ), + ], + ids=["r_zero", "s_zero", "v_zero", "v_29", "v_255"], +) +def test_invalid_signature_values( + transaction_test: TransactionTestFiller, + pre: Alloc, + fork: Fork, + field: str, + payload: bytes, + error: TransactionException | list[TransactionException], +) -> None: + """ + Replace a signature field with a well-encoded but invalid value: + zero r or s, or a v that is neither 27, 28 nor an EIP-155 value. + + Before EIP-155 any v other than 27 or 28 is a plain invalid v; + afterwards clients may instead derive a chain id from the invalid + v and reject the transaction for the chain id mismatch. + """ + fields = signed_tx_fields(pre, fork) + fields[field] = payload + rlp = encode_tx(fields) + transaction_test(pre=pre, tx=invalid_tx(pre, rlp, error)) diff --git a/tests/shanghai/eip4895_withdrawals/test_withdrawals.py b/tests/shanghai/eip4895_withdrawals/test_withdrawals.py index 76ecad0b672..26e759e453c 100644 --- a/tests/shanghai/eip4895_withdrawals/test_withdrawals.py +++ b/tests/shanghai/eip4895_withdrawals/test_withdrawals.py @@ -673,16 +673,13 @@ def test_zero_amount( withdrawals = all_withdrawals[0:2] post = { account: all_post[account] - for account in post - if account in [empty_accounts[0], zero_balance_contract] + for account in [empty_accounts[0], zero_balance_contract] } elif test_case == ZeroAmountTestCases.THREE_ONE_WITH_VALUE: withdrawals = all_withdrawals[0:3] post = { account: all_post[account] - for account in post - if account - in [ + for account in [ empty_accounts[0], zero_balance_contract, empty_accounts[1],