From 459872749ad4a7449fa0fc7c366b771d9e8c2740 Mon Sep 17 00:00:00 2001 From: danceratopz Date: Tue, 1 Sep 2026 17:16:52 +0200 Subject: [PATCH 1/6] fix(test-specs): calculate transaction fixture gas from context (#3491) --- .../specs/tests/test_transaction.py | 44 ++++++++++++++++++- .../execution_testing/specs/transaction.py | 7 +++ 2 files changed, 50 insertions(+), 1 deletion(-) 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, From 2909015e4275d047cebfc119653c0e123c8689cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Tue, 1 Sep 2026 17:20:35 +0200 Subject: [PATCH 2/6] feat(tests): add type-0 transaction RLP validity tests (#3156) * feat(tests): add type-0 transaction RLP validity tests Port the core malformation classes of the legacy TransactionTests suites (ttWrongRLP, ttNonce, ttValue, ttRSValue, ttVValue, ttAddress), which were never converted because the ported-static pipeline only handles state-test fillers and the raw malformed bytes cannot round-trip through a structured transaction model. A local RLP encoder builds each corruption deliberately, since a correct encoder cannot emit non-canonical forms: per-field leading zeros, 33-byte field overflows, 19 and 21 byte addresses, fields encoded as lists, structural corruptions of the outer list (truncation, trailing bytes, wrong element counts, header size mismatches, size with leading zeros), and well-encoded but invalid signature values. A valid re-encoded control case anchors the encoder to the framework's byte-exact output. The transaction_test fixture format records the declared exception without consulting the transition tool, so all 30 cases were verified externally by feeding the generated fixture bytes through EELS decode_transaction, recover_sender and validate_transaction at Frontier, London and Cancun: every invalid vector is rejected and the control is accepted with the matching sender. Notably the gas limit and gas price are unbounded scalars in the spec, so their oversized encodings are valid at the transaction level; the overflow cases cover the 256-bit bounded fields (nonce, value, r, s) only. * fix(tests): accept client-divergent transaction RLP exceptions Declare exception lists where clients legitimately report different errors for the same malformed transaction: - `header_declares_less`: the mutation 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. - `v_29`: post EIP-155 clients may derive a chain id from any v other than 27 or 28 and reject the mismatch instead of the signature, as already documented in `test_bad_v_r_s`. * chore(tests): correct the transaction field overflow docstring The nonce is decoded as a 256-bit scalar by the spec; the 64-bit bound is an EIP-2681 validation rule, not a decoding one. Also note that the signature v is a bounded 256-bit field whose oversized encoding is uncovered only because no field-specific decoding exception exists. * feat(tests): add r and s field-as-list transaction RLP cases Extend `test_field_as_list` to the signature r and s fields, porting `TRANSCT_rvalue_GivenAsListCopier` and `TRANSCT_svalue_GivenAsListCopier` with the same `RLP_INVALID_SIGNATURE_R`/`_S` exceptions the legacy suite declares. The gas price and v fields remain uncovered for lack of a field-specific decoding exception. * feat(tests): add a non-canonical single-byte transaction RLP case Encode the single-byte nonce payload behind a one-byte string header (0x8101) instead of as the byte itself. This ports the `RLPIncorrectByteEncoding{00,01,127}Copier` legacy tests, which corrupt the nonce this way and declare `RLP_LEADING_ZEROS_NONCE_SIZE`. * feat(tests): add a data size leading zeros transaction RLP case Encode the size of the data field's long-form string header with a leading zero byte, porting `RLPArrayLengthWithFirstZerosCopier` with the `RLP_LEADING_ZEROS_DATA_SIZE` exception it declares. This covers the string-header variant of the list-header case already tested by the `list_size_leading_zeros` mutation. * feat(tests): add a zero v transaction signature case A zero v is well-encoded (empty payload) but is neither 27, 28 nor an EIP-155 value. Declare `INVALID_CHAINID` as an acceptable alternative for the same reason as the other invalid v cases: post EIP-155 clients may derive a chain id from any v other than 27 or 28. * chore(tests): cite more covered legacy transaction test fillers Add `ported_from` references for legacy fillers whose malformation class is already exercised by an existing case: - Leading zeros: the `tt{Nonce,GasPrice,GasLimit,Value}` zero-prefixed fillers and the `TRANSCT_*_Prefixed0000` copiers. - Overflow: the `TRANSCT_{r,s}value_TooLarge` copiers. - Address size: `AddressMoreThan20` and the `TRANSCT_to_*` copiers. - Field as list: the remaining `TRANSCT_*_GivenAsList` copiers. - Structure: `RLPTransactionGivenAsArray`, matching the `tx_as_byte_string` mutation. All referenced fillers were inspected at the pinned commit to confirm the corruption and declared exception match the covering case. * fix(tests): fund only senders that send in transaction RLP tests In execute mode, `pre.fund_eoa()` defers the funding amount until the EOA sends a transaction; an EOA that never sends one fails the run with "Sender balance must be set before sending". The senders of the corrupted transactions never send: only their raw serialization is submitted, expecting rejection. Fund them with `amount=0` so execute mode derives an address without scheduling a funding transaction. The signing keys are derived from the account content, so the corrupted vectors' bytes change; all vectors were re-verified against EELS decoding and validation at Frontier, London and Cancun. * chore(tests): mark transaction RLP tests as inclusion tests Each case asserts whether one transaction can be included in a block, which is what the `inclusion_test` marker denotes. * fix(tests): accept a type error for a transaction given as a byte string EIP-2718 reads a byte string in the transaction list as a typed transaction, so from Berlin on the corruption is reported as an unsupported transaction type rather than an RLP header error. Verified against EELS decoding at Frontier, Berlin and Cancun. --------- Co-authored-by: danceratopz --- .../validation/test_transaction_rlp.py | 538 ++++++++++++++++++ 1 file changed, 538 insertions(+) create mode 100644 tests/frontier/validation/test_transaction_rlp.py 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)) From ef914fe55dd252b60a718fec013ae2a6ee15054e Mon Sep 17 00:00:00 2001 From: felipe Date: Tue, 1 Sep 2026 11:44:01 -0600 Subject: [PATCH 3/6] chore(tests): clean up EIP-7928 spec.py ``Spec`` class (#3496) No spec-specific constants were useful to add here. This may change but the import from the relevant spec is preferred over adding them here if they are not BALs specific. --- .../eip7928_block_level_access_lists/spec.py | 24 +------------------ .../test_block_access_lists_opcodes.py | 16 +++++++------ 2 files changed, 10 insertions(+), 30 deletions(-) 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, }, From 70c3511ba1b454c6f0458e1e1b949cf383a6894e Mon Sep 17 00:00:00 2001 From: spencer Date: Tue, 1 Sep 2026 21:19:20 +0200 Subject: [PATCH 4/6] feat(tests): pin cross-frame state gas refund placement and settlement (#3490) * feat(tests): pin cross-frame state gas refund placement and settlement * feat(tests): EIP-8037 cross-frame refund split across a child's own spill Test that one frame's refund both repays a different slot's borrow and puts the excess in the reservoir, that the split state merges cleanly on success, and that it is fully unwound on revert and halt. * chore(tests): use fork transaction gas limit cap, not constant val * fix: apply comments from PR #3490 --------- Co-authored-by: fselmo --- .../test_state_gas_cross_frame_refund.py | 516 ++++++++++++++++++ 1 file changed, 516 insertions(+) create mode 100644 tests/amsterdam/eip8037_state_creation_gas_cost_increase/test_state_gas_cross_frame_refund.py 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) From 117c9240c86ececb57440cd3342b4b82688fec06 Mon Sep 17 00:00:00 2001 From: Om Kumar Date: Wed, 2 Sep 2026 02:08:42 +0530 Subject: [PATCH 5/6] fix(tests): assert zero withdrawal post-state (#3476) --- tests/shanghai/eip4895_withdrawals/test_withdrawals.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) 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], From 739c87067d4d9b2677d017bdc90ad915e61c099e Mon Sep 17 00:00:00 2001 From: chugarchugarr Date: Tue, 1 Sep 2026 16:15:07 -0500 Subject: [PATCH 6/6] chore(eip-8198): refresh candidate with canonical test placement --- .../tests/eip8198_quick_slots/__init__.py | 1 + .../test_additional_duration_era.py | 103 +++++++ .../eip8198_quick_slots/test_slot_timing.py | 263 ++++++++++++++++++ .../test_vm_blob_schedule.py | 61 ++++ src/ethereum/forks/amsterdam/fork.py | 117 ++++---- src/ethereum/forks/amsterdam/slot_timing.py | 220 +++++++++++++++ src/ethereum/forks/amsterdam/vm/gas.py | 163 +++++------ .../amsterdam/vm/instructions/environment.py | 5 +- 8 files changed, 791 insertions(+), 142 deletions(-) create mode 100644 packages/testing/src/execution_testing/evm_tools/tests/eip8198_quick_slots/__init__.py create mode 100644 packages/testing/src/execution_testing/evm_tools/tests/eip8198_quick_slots/test_additional_duration_era.py create mode 100644 packages/testing/src/execution_testing/evm_tools/tests/eip8198_quick_slots/test_slot_timing.py create mode 100644 packages/testing/src/execution_testing/evm_tools/tests/eip8198_quick_slots/test_vm_blob_schedule.py create mode 100644 src/ethereum/forks/amsterdam/slot_timing.py 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/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