From ba7cedd37bbbc51a9d41df4ff15ea4995ae579ba Mon Sep 17 00:00:00 2001 From: danceratopz Date: Mon, 13 Jul 2026 20:25:10 +0200 Subject: [PATCH 1/8] 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`. --- .../validation/test_transaction_rlp.py | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/tests/frontier/validation/test_transaction_rlp.py b/tests/frontier/validation/test_transaction_rlp.py index fb3fbbb53a..62c232bcf3 100644 --- a/tests/frontier/validation/test_transaction_rlp.py +++ b/tests/frontier/validation/test_transaction_rlp.py @@ -293,7 +293,13 @@ def test_field_as_list( ("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), + ( + "header_declares_less", + [ + TransactionException.RLP_ERROR_EOF, + TransactionException.RLP_ERROR_SIZE, + ], + ), ("tx_as_byte_string", TransactionException.RLP_INVALID_HEADER), ( "list_size_leading_zeros", @@ -306,9 +312,15 @@ def test_invalid_structure( pre: Alloc, fork: Fork, mutation: str, - error: TransactionException, + error: TransactionException | list[TransactionException], ) -> None: - """Corrupt the RLP structure of the whole transaction.""" + """ + 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. + """ fields = signed_tx_fields(pre, fork) items = [rlp_bytes(fields[name]) for name in fields] payload = b"".join(items) @@ -350,7 +362,14 @@ def test_invalid_structure( [ ("r", b"", TransactionException.INVALID_SIGNATURE_VRS), ("s", b"", TransactionException.INVALID_SIGNATURE_VRS), - ("v", b"\x1d", TransactionException.INVALID_SIGNATURE_VRS), + ( + "v", + b"\x1d", + [ + TransactionException.INVALID_SIGNATURE_VRS, + TransactionException.INVALID_CHAINID, + ], + ), ( "v", b"\xff", @@ -374,8 +393,9 @@ def test_invalid_signature_values( 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 a v of 255 is a plain invalid v; afterwards it is - interpreted as a chain id that does not match the chain. + 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 From 3f5cf59a09c58b2f0eed53300a080faf20a2389a Mon Sep 17 00:00:00 2001 From: danceratopz Date: Mon, 13 Jul 2026 20:25:47 +0200 Subject: [PATCH 2/8] 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. --- tests/frontier/validation/test_transaction_rlp.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/frontier/validation/test_transaction_rlp.py b/tests/frontier/validation/test_transaction_rlp.py index 62c232bcf3..dc46bfea1a 100644 --- a/tests/frontier/validation/test_transaction_rlp.py +++ b/tests/frontier/validation/test_transaction_rlp.py @@ -194,11 +194,16 @@ def test_field_overflow( ) -> None: """ Encode one integer field as a 33-byte value (2**256), exceeding the - field's width (64 bits for the nonce, 256 bits for the others). + 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. + 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)) From 27bb5cf869407cb062ef1771d05b62f9013fe947 Mon Sep 17 00:00:00 2001 From: danceratopz Date: Mon, 13 Jul 2026 20:26:37 +0200 Subject: [PATCH 3/8] 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. --- tests/frontier/validation/test_transaction_rlp.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/frontier/validation/test_transaction_rlp.py b/tests/frontier/validation/test_transaction_rlp.py index dc46bfea1a..acd506b196 100644 --- a/tests/frontier/validation/test_transaction_rlp.py +++ b/tests/frontier/validation/test_transaction_rlp.py @@ -253,6 +253,8 @@ def test_to_address_size( "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", ], ) @pytest.mark.exception_test @@ -264,6 +266,8 @@ def test_to_address_size( ("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( @@ -273,7 +277,12 @@ def test_field_as_list( field: str, error: TransactionException, ) -> None: - """Encode one field as an RLP list instead of a byte string.""" + """ + 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}) From 243eec565cd7c34036e4cf37c0d0a61b866205bb Mon Sep 17 00:00:00 2001 From: danceratopz Date: Mon, 13 Jul 2026 20:27:52 +0200 Subject: [PATCH 4/8] 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`. --- .../validation/test_transaction_rlp.py | 39 +++++++++++++++++-- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/tests/frontier/validation/test_transaction_rlp.py b/tests/frontier/validation/test_transaction_rlp.py index acd506b196..17702b1fff 100644 --- a/tests/frontier/validation/test_transaction_rlp.py +++ b/tests/frontier/validation/test_transaction_rlp.py @@ -63,11 +63,12 @@ def tx_fields(tx: Transaction) -> Dict[str, bytes]: } -def signed_tx(pre: Alloc, fork: Fork) -> Transaction: +def signed_tx(pre: Alloc, fork: Fork, nonce: int = 0) -> Transaction: """Build and sign the base type-0 transaction.""" return Transaction( sender=pre.fund_eoa(), to=pre.fund_eoa(amount=0), + nonce=nonce, gas_price=10, gas_limit=30_000, value=1, @@ -75,9 +76,11 @@ def signed_tx(pre: Alloc, fork: Fork) -> Transaction: ).with_signature_and_sender() -def signed_tx_fields(pre: Alloc, fork: Fork) -> Dict[str, bytes]: +def signed_tx_fields( + pre: Alloc, fork: Fork, nonce: int = 0 +) -> Dict[str, bytes]: """Build a signed type-0 transaction and decompose it.""" - return tx_fields(signed_tx(pre, fork)) + return tx_fields(signed_tx(pre, fork, nonce=nonce)) def encode_tx( @@ -167,6 +170,36 @@ def test_field_leading_zeros( 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}/ttNonce/TransactionWithNonceOverflowFiller.json", From 8ccb9cf902b9815c4955f7e985952204fa688d82 Mon Sep 17 00:00:00 2001 From: danceratopz Date: Mon, 13 Jul 2026 20:29:01 +0200 Subject: [PATCH 5/8] 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. --- .../validation/test_transaction_rlp.py | 41 +++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/tests/frontier/validation/test_transaction_rlp.py b/tests/frontier/validation/test_transaction_rlp.py index 17702b1fff..610e44c423 100644 --- a/tests/frontier/validation/test_transaction_rlp.py +++ b/tests/frontier/validation/test_transaction_rlp.py @@ -63,7 +63,9 @@ def tx_fields(tx: Transaction) -> Dict[str, bytes]: } -def signed_tx(pre: Alloc, fork: Fork, nonce: int = 0) -> Transaction: +def signed_tx( + pre: Alloc, fork: Fork, nonce: int = 0, data: bytes = b"" +) -> Transaction: """Build and sign the base type-0 transaction.""" return Transaction( sender=pre.fund_eoa(), @@ -72,15 +74,16 @@ def signed_tx(pre: Alloc, fork: Fork, nonce: int = 0) -> Transaction: 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 + 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)) + return tx_fields(signed_tx(pre, fork, nonce=nonce, data=data)) def encode_tx( @@ -200,6 +203,38 @@ def test_non_canonical_single_byte( ) +@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", From f1c6897575de8b185725e2d57c291e8c61272532 Mon Sep 17 00:00:00 2001 From: danceratopz Date: Mon, 13 Jul 2026 20:29:56 +0200 Subject: [PATCH 6/8] 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. --- tests/frontier/validation/test_transaction_rlp.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/frontier/validation/test_transaction_rlp.py b/tests/frontier/validation/test_transaction_rlp.py index 610e44c423..6eeba85617 100644 --- a/tests/frontier/validation/test_transaction_rlp.py +++ b/tests/frontier/validation/test_transaction_rlp.py @@ -444,6 +444,14 @@ def test_invalid_structure( [ ("r", b"", TransactionException.INVALID_SIGNATURE_VRS), ("s", b"", TransactionException.INVALID_SIGNATURE_VRS), + ( + "v", + b"", + [ + TransactionException.INVALID_SIGNATURE_VRS, + TransactionException.INVALID_CHAINID, + ], + ), ( "v", b"\x1d", @@ -461,7 +469,7 @@ def test_invalid_structure( ], ), ], - ids=["r_zero", "s_zero", "v_29", "v_255"], + ids=["r_zero", "s_zero", "v_zero", "v_29", "v_255"], ) def test_invalid_signature_values( transaction_test: TransactionTestFiller, From 0e8ec204714896e77521cededa5f04478b824c0e Mon Sep 17 00:00:00 2001 From: danceratopz Date: Mon, 13 Jul 2026 20:32:08 +0200 Subject: [PATCH 7/8] 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. --- .../validation/test_transaction_rlp.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/frontier/validation/test_transaction_rlp.py b/tests/frontier/validation/test_transaction_rlp.py index 6eeba85617..7958f72ce2 100644 --- a/tests/frontier/validation/test_transaction_rlp.py +++ b/tests/frontier/validation/test_transaction_rlp.py @@ -140,6 +140,16 @@ def test_valid_reencoded_transaction( 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 @@ -241,6 +251,8 @@ def test_data_size_leading_zeros( 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 @@ -285,6 +297,10 @@ def test_field_overflow( 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 @@ -323,6 +339,9 @@ def test_to_address_size( "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 @@ -364,6 +383,7 @@ def test_field_as_list( 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 From b5f21f2ea300b11c292637099bbe523761503296 Mon Sep 17 00:00:00 2001 From: danceratopz Date: Tue, 14 Jul 2026 01:38:50 +0200 Subject: [PATCH 8/8] 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. --- .../validation/test_transaction_rlp.py | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/tests/frontier/validation/test_transaction_rlp.py b/tests/frontier/validation/test_transaction_rlp.py index 7958f72ce2..d1728686fc 100644 --- a/tests/frontier/validation/test_transaction_rlp.py +++ b/tests/frontier/validation/test_transaction_rlp.py @@ -64,11 +64,21 @@ def tx_fields(tx: Transaction) -> Dict[str, bytes]: def signed_tx( - pre: Alloc, fork: Fork, nonce: int = 0, data: bytes = b"" + pre: Alloc, + fork: Fork, + nonce: int = 0, + data: bytes = b"", + funded: bool = True, ) -> Transaction: - """Build and sign the base type-0 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(), + sender=pre.fund_eoa() if funded else pre.fund_eoa(amount=0), to=pre.fund_eoa(amount=0), nonce=nonce, gas_price=10, @@ -83,7 +93,9 @@ 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)) + return tx_fields( + signed_tx(pre, fork, nonce=nonce, data=data, funded=False) + ) def encode_tx( @@ -106,7 +118,7 @@ def invalid_tx( ) -> Transaction: """Return a transaction whose serialization is the given raw bytes.""" tx = Transaction( - sender=pre.fund_eoa(), + sender=pre.fund_eoa(amount=0), to=0, gas_price=10, gas_limit=30_000,