From 6b0941f598d663e7ac3627dd6b0f052ee0fe8ae3 Mon Sep 17 00:00:00 2001 From: fselmo Date: Wed, 14 Oct 2026 12:15:28 -0600 Subject: [PATCH 1/4] fix(specs): reject legacy chain ids over 64 bits chain_id() narrowed the chain ID derived from a legacy v to U64, so a v of 2**65 + 35 or more raised OverflowError and crashed block processing instead of rejecting the transaction. It now raises InvalidSignatureError("bad v") for a chain ID that does not fit in 64 bits, as mainnet's recover_sender does for the same input. Co-Authored-By: Claude Opus 5.5 --- src/ethereum/forks/amsterdam/transactions.py | 5 ++++- src/ethereum/forks/arrow_glacier/transactions.py | 5 ++++- src/ethereum/forks/berlin/transactions.py | 5 ++++- src/ethereum/forks/bogota/transactions.py | 5 ++++- src/ethereum/forks/bpo1/transactions.py | 5 ++++- src/ethereum/forks/bpo2/transactions.py | 5 ++++- src/ethereum/forks/bpo3/transactions.py | 5 ++++- src/ethereum/forks/bpo4/transactions.py | 5 ++++- src/ethereum/forks/bpo5/transactions.py | 5 ++++- src/ethereum/forks/byzantium/transactions.py | 5 ++++- src/ethereum/forks/cancun/transactions.py | 5 ++++- src/ethereum/forks/constantinople/transactions.py | 5 ++++- src/ethereum/forks/gray_glacier/transactions.py | 5 ++++- src/ethereum/forks/istanbul/transactions.py | 5 ++++- src/ethereum/forks/london/transactions.py | 5 ++++- src/ethereum/forks/muir_glacier/transactions.py | 5 ++++- src/ethereum/forks/osaka/transactions.py | 5 ++++- src/ethereum/forks/paris/transactions.py | 5 ++++- src/ethereum/forks/prague/transactions.py | 5 ++++- src/ethereum/forks/shanghai/transactions.py | 5 ++++- src/ethereum/forks/spurious_dragon/transactions.py | 5 ++++- 21 files changed, 84 insertions(+), 21 deletions(-) diff --git a/src/ethereum/forks/amsterdam/transactions.py b/src/ethereum/forks/amsterdam/transactions.py index cd80add62f9..b729bff1e3b 100644 --- a/src/ethereum/forks/amsterdam/transactions.py +++ b/src/ethereum/forks/amsterdam/transactions.py @@ -871,7 +871,10 @@ def chain_id(tx: Transaction) -> None | U64: if tx.v < U256(35): raise InvalidSignatureError("bad v") - return U64((tx.v - U256(35)) >> U256(1)) + tx_chain_id = (tx.v - U256(35)) >> U256(1) + if tx_chain_id > U256(U64.MAX_VALUE): + raise InvalidSignatureError("bad v") + return U64(tx_chain_id) else: return tx.chain_id diff --git a/src/ethereum/forks/arrow_glacier/transactions.py b/src/ethereum/forks/arrow_glacier/transactions.py index fd3c460acfd..154aee27c8d 100644 --- a/src/ethereum/forks/arrow_glacier/transactions.py +++ b/src/ethereum/forks/arrow_glacier/transactions.py @@ -397,7 +397,10 @@ def chain_id(tx: Transaction) -> None | U64: if tx.v < U256(35): raise InvalidSignatureError("bad v") - return U64((tx.v - U256(35)) >> U256(1)) + tx_chain_id = (tx.v - U256(35)) >> U256(1) + if tx_chain_id > U256(U64.MAX_VALUE): + raise InvalidSignatureError("bad v") + return U64(tx_chain_id) else: return tx.chain_id diff --git a/src/ethereum/forks/berlin/transactions.py b/src/ethereum/forks/berlin/transactions.py index a36ed5e5da1..f0c639defae 100644 --- a/src/ethereum/forks/berlin/transactions.py +++ b/src/ethereum/forks/berlin/transactions.py @@ -305,7 +305,10 @@ def chain_id(tx: Transaction) -> None | U64: if tx.v < U256(35): raise InvalidSignatureError("bad v") - return U64((tx.v - U256(35)) >> U256(1)) + tx_chain_id = (tx.v - U256(35)) >> U256(1) + if tx_chain_id > U256(U64.MAX_VALUE): + raise InvalidSignatureError("bad v") + return U64(tx_chain_id) else: return tx.chain_id diff --git a/src/ethereum/forks/bogota/transactions.py b/src/ethereum/forks/bogota/transactions.py index b7c39527559..1a47fdb86b7 100644 --- a/src/ethereum/forks/bogota/transactions.py +++ b/src/ethereum/forks/bogota/transactions.py @@ -871,7 +871,10 @@ def chain_id(tx: Transaction) -> None | U64: if tx.v < U256(35): raise InvalidSignatureError("bad v") - return U64((tx.v - U256(35)) >> U256(1)) + tx_chain_id = (tx.v - U256(35)) >> U256(1) + if tx_chain_id > U256(U64.MAX_VALUE): + raise InvalidSignatureError("bad v") + return U64(tx_chain_id) else: return tx.chain_id diff --git a/src/ethereum/forks/bpo1/transactions.py b/src/ethereum/forks/bpo1/transactions.py index d125e1fdb4a..5eeb34bbff0 100644 --- a/src/ethereum/forks/bpo1/transactions.py +++ b/src/ethereum/forks/bpo1/transactions.py @@ -674,7 +674,10 @@ def chain_id(tx: Transaction) -> None | U64: if tx.v < U256(35): raise InvalidSignatureError("bad v") - return U64((tx.v - U256(35)) >> U256(1)) + tx_chain_id = (tx.v - U256(35)) >> U256(1) + if tx_chain_id > U256(U64.MAX_VALUE): + raise InvalidSignatureError("bad v") + return U64(tx_chain_id) else: return tx.chain_id diff --git a/src/ethereum/forks/bpo2/transactions.py b/src/ethereum/forks/bpo2/transactions.py index 366e87498ff..d443d269c03 100644 --- a/src/ethereum/forks/bpo2/transactions.py +++ b/src/ethereum/forks/bpo2/transactions.py @@ -674,7 +674,10 @@ def chain_id(tx: Transaction) -> None | U64: if tx.v < U256(35): raise InvalidSignatureError("bad v") - return U64((tx.v - U256(35)) >> U256(1)) + tx_chain_id = (tx.v - U256(35)) >> U256(1) + if tx_chain_id > U256(U64.MAX_VALUE): + raise InvalidSignatureError("bad v") + return U64(tx_chain_id) else: return tx.chain_id diff --git a/src/ethereum/forks/bpo3/transactions.py b/src/ethereum/forks/bpo3/transactions.py index a76a9ddd61e..9e1d991ce93 100644 --- a/src/ethereum/forks/bpo3/transactions.py +++ b/src/ethereum/forks/bpo3/transactions.py @@ -674,7 +674,10 @@ def chain_id(tx: Transaction) -> None | U64: if tx.v < U256(35): raise InvalidSignatureError("bad v") - return U64((tx.v - U256(35)) >> U256(1)) + tx_chain_id = (tx.v - U256(35)) >> U256(1) + if tx_chain_id > U256(U64.MAX_VALUE): + raise InvalidSignatureError("bad v") + return U64(tx_chain_id) else: return tx.chain_id diff --git a/src/ethereum/forks/bpo4/transactions.py b/src/ethereum/forks/bpo4/transactions.py index 65867d529b1..2e9f95f7588 100644 --- a/src/ethereum/forks/bpo4/transactions.py +++ b/src/ethereum/forks/bpo4/transactions.py @@ -674,7 +674,10 @@ def chain_id(tx: Transaction) -> None | U64: if tx.v < U256(35): raise InvalidSignatureError("bad v") - return U64((tx.v - U256(35)) >> U256(1)) + tx_chain_id = (tx.v - U256(35)) >> U256(1) + if tx_chain_id > U256(U64.MAX_VALUE): + raise InvalidSignatureError("bad v") + return U64(tx_chain_id) else: return tx.chain_id diff --git a/src/ethereum/forks/bpo5/transactions.py b/src/ethereum/forks/bpo5/transactions.py index 3e4a1b3c7ef..c72fb87d488 100644 --- a/src/ethereum/forks/bpo5/transactions.py +++ b/src/ethereum/forks/bpo5/transactions.py @@ -674,7 +674,10 @@ def chain_id(tx: Transaction) -> None | U64: if tx.v < U256(35): raise InvalidSignatureError("bad v") - return U64((tx.v - U256(35)) >> U256(1)) + tx_chain_id = (tx.v - U256(35)) >> U256(1) + if tx_chain_id > U256(U64.MAX_VALUE): + raise InvalidSignatureError("bad v") + return U64(tx_chain_id) else: return tx.chain_id diff --git a/src/ethereum/forks/byzantium/transactions.py b/src/ethereum/forks/byzantium/transactions.py index f599f94e062..7ddf68b3450 100644 --- a/src/ethereum/forks/byzantium/transactions.py +++ b/src/ethereum/forks/byzantium/transactions.py @@ -157,7 +157,10 @@ def chain_id(tx: Transaction) -> None | U64: return None if tx.v < U256(35): raise InvalidSignatureError("bad v") - return U64((tx.v - U256(35)) >> U256(1)) + tx_chain_id = (tx.v - U256(35)) >> U256(1) + if tx_chain_id > U256(U64.MAX_VALUE): + raise InvalidSignatureError("bad v") + return U64(tx_chain_id) def recover_sender(tx: Transaction) -> Address: diff --git a/src/ethereum/forks/cancun/transactions.py b/src/ethereum/forks/cancun/transactions.py index 291bf256c63..d608759b5cf 100644 --- a/src/ethereum/forks/cancun/transactions.py +++ b/src/ethereum/forks/cancun/transactions.py @@ -524,7 +524,10 @@ def chain_id(tx: Transaction) -> None | U64: if tx.v < U256(35): raise InvalidSignatureError("bad v") - return U64((tx.v - U256(35)) >> U256(1)) + tx_chain_id = (tx.v - U256(35)) >> U256(1) + if tx_chain_id > U256(U64.MAX_VALUE): + raise InvalidSignatureError("bad v") + return U64(tx_chain_id) else: return tx.chain_id diff --git a/src/ethereum/forks/constantinople/transactions.py b/src/ethereum/forks/constantinople/transactions.py index f599f94e062..7ddf68b3450 100644 --- a/src/ethereum/forks/constantinople/transactions.py +++ b/src/ethereum/forks/constantinople/transactions.py @@ -157,7 +157,10 @@ def chain_id(tx: Transaction) -> None | U64: return None if tx.v < U256(35): raise InvalidSignatureError("bad v") - return U64((tx.v - U256(35)) >> U256(1)) + tx_chain_id = (tx.v - U256(35)) >> U256(1) + if tx_chain_id > U256(U64.MAX_VALUE): + raise InvalidSignatureError("bad v") + return U64(tx_chain_id) def recover_sender(tx: Transaction) -> Address: diff --git a/src/ethereum/forks/gray_glacier/transactions.py b/src/ethereum/forks/gray_glacier/transactions.py index fd3c460acfd..154aee27c8d 100644 --- a/src/ethereum/forks/gray_glacier/transactions.py +++ b/src/ethereum/forks/gray_glacier/transactions.py @@ -397,7 +397,10 @@ def chain_id(tx: Transaction) -> None | U64: if tx.v < U256(35): raise InvalidSignatureError("bad v") - return U64((tx.v - U256(35)) >> U256(1)) + tx_chain_id = (tx.v - U256(35)) >> U256(1) + if tx_chain_id > U256(U64.MAX_VALUE): + raise InvalidSignatureError("bad v") + return U64(tx_chain_id) else: return tx.chain_id diff --git a/src/ethereum/forks/istanbul/transactions.py b/src/ethereum/forks/istanbul/transactions.py index f599f94e062..7ddf68b3450 100644 --- a/src/ethereum/forks/istanbul/transactions.py +++ b/src/ethereum/forks/istanbul/transactions.py @@ -157,7 +157,10 @@ def chain_id(tx: Transaction) -> None | U64: return None if tx.v < U256(35): raise InvalidSignatureError("bad v") - return U64((tx.v - U256(35)) >> U256(1)) + tx_chain_id = (tx.v - U256(35)) >> U256(1) + if tx_chain_id > U256(U64.MAX_VALUE): + raise InvalidSignatureError("bad v") + return U64(tx_chain_id) def recover_sender(tx: Transaction) -> Address: diff --git a/src/ethereum/forks/london/transactions.py b/src/ethereum/forks/london/transactions.py index fd3c460acfd..154aee27c8d 100644 --- a/src/ethereum/forks/london/transactions.py +++ b/src/ethereum/forks/london/transactions.py @@ -397,7 +397,10 @@ def chain_id(tx: Transaction) -> None | U64: if tx.v < U256(35): raise InvalidSignatureError("bad v") - return U64((tx.v - U256(35)) >> U256(1)) + tx_chain_id = (tx.v - U256(35)) >> U256(1) + if tx_chain_id > U256(U64.MAX_VALUE): + raise InvalidSignatureError("bad v") + return U64(tx_chain_id) else: return tx.chain_id diff --git a/src/ethereum/forks/muir_glacier/transactions.py b/src/ethereum/forks/muir_glacier/transactions.py index f599f94e062..7ddf68b3450 100644 --- a/src/ethereum/forks/muir_glacier/transactions.py +++ b/src/ethereum/forks/muir_glacier/transactions.py @@ -157,7 +157,10 @@ def chain_id(tx: Transaction) -> None | U64: return None if tx.v < U256(35): raise InvalidSignatureError("bad v") - return U64((tx.v - U256(35)) >> U256(1)) + tx_chain_id = (tx.v - U256(35)) >> U256(1) + if tx_chain_id > U256(U64.MAX_VALUE): + raise InvalidSignatureError("bad v") + return U64(tx_chain_id) def recover_sender(tx: Transaction) -> Address: diff --git a/src/ethereum/forks/osaka/transactions.py b/src/ethereum/forks/osaka/transactions.py index f0e692ca7b7..4411975cb7a 100644 --- a/src/ethereum/forks/osaka/transactions.py +++ b/src/ethereum/forks/osaka/transactions.py @@ -674,7 +674,10 @@ def chain_id(tx: Transaction) -> None | U64: if tx.v < U256(35): raise InvalidSignatureError("bad v") - return U64((tx.v - U256(35)) >> U256(1)) + tx_chain_id = (tx.v - U256(35)) >> U256(1) + if tx_chain_id > U256(U64.MAX_VALUE): + raise InvalidSignatureError("bad v") + return U64(tx_chain_id) else: return tx.chain_id diff --git a/src/ethereum/forks/paris/transactions.py b/src/ethereum/forks/paris/transactions.py index fd3c460acfd..154aee27c8d 100644 --- a/src/ethereum/forks/paris/transactions.py +++ b/src/ethereum/forks/paris/transactions.py @@ -397,7 +397,10 @@ def chain_id(tx: Transaction) -> None | U64: if tx.v < U256(35): raise InvalidSignatureError("bad v") - return U64((tx.v - U256(35)) >> U256(1)) + tx_chain_id = (tx.v - U256(35)) >> U256(1) + if tx_chain_id > U256(U64.MAX_VALUE): + raise InvalidSignatureError("bad v") + return U64(tx_chain_id) else: return tx.chain_id diff --git a/src/ethereum/forks/prague/transactions.py b/src/ethereum/forks/prague/transactions.py index e4ee0268207..79891c90cb5 100644 --- a/src/ethereum/forks/prague/transactions.py +++ b/src/ethereum/forks/prague/transactions.py @@ -670,7 +670,10 @@ def chain_id(tx: Transaction) -> None | U64: if tx.v < U256(35): raise InvalidSignatureError("bad v") - return U64((tx.v - U256(35)) >> U256(1)) + tx_chain_id = (tx.v - U256(35)) >> U256(1) + if tx_chain_id > U256(U64.MAX_VALUE): + raise InvalidSignatureError("bad v") + return U64(tx_chain_id) else: return tx.chain_id diff --git a/src/ethereum/forks/shanghai/transactions.py b/src/ethereum/forks/shanghai/transactions.py index 0a88f6549f5..4cb3fe9eb64 100644 --- a/src/ethereum/forks/shanghai/transactions.py +++ b/src/ethereum/forks/shanghai/transactions.py @@ -410,7 +410,10 @@ def chain_id(tx: Transaction) -> None | U64: if tx.v < U256(35): raise InvalidSignatureError("bad v") - return U64((tx.v - U256(35)) >> U256(1)) + tx_chain_id = (tx.v - U256(35)) >> U256(1) + if tx_chain_id > U256(U64.MAX_VALUE): + raise InvalidSignatureError("bad v") + return U64(tx_chain_id) else: return tx.chain_id diff --git a/src/ethereum/forks/spurious_dragon/transactions.py b/src/ethereum/forks/spurious_dragon/transactions.py index f599f94e062..7ddf68b3450 100644 --- a/src/ethereum/forks/spurious_dragon/transactions.py +++ b/src/ethereum/forks/spurious_dragon/transactions.py @@ -157,7 +157,10 @@ def chain_id(tx: Transaction) -> None | U64: return None if tx.v < U256(35): raise InvalidSignatureError("bad v") - return U64((tx.v - U256(35)) >> U256(1)) + tx_chain_id = (tx.v - U256(35)) >> U256(1) + if tx_chain_id > U256(U64.MAX_VALUE): + raise InvalidSignatureError("bad v") + return U64(tx_chain_id) def recover_sender(tx: Transaction) -> Address: From b09e3604741c9af5a08673315a58239905dd6724 Mon Sep 17 00:00:00 2001 From: fselmo Date: Wed, 14 Oct 2026 12:15:28 -0600 Subject: [PATCH 2/4] feat(tests): add legacy chain ids at the 64-bit boundary Adds legacy transactions whose v encodes the largest chain ID that fits in 64 bits (INVALID_CHAINID), the smallest that does not, and this chain's ID plus 2**64 (both INVALID_SIGNATURE_VRS), from Spurious Dragon on. Co-Authored-By: Claude Opus 5.5 --- tests/frontier/validation/test_transaction.py | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/tests/frontier/validation/test_transaction.py b/tests/frontier/validation/test_transaction.py index 13ad3e418a2..c228aabd76b 100644 --- a/tests/frontier/validation/test_transaction.py +++ b/tests/frontier/validation/test_transaction.py @@ -8,6 +8,8 @@ BalBalanceChange, BalNonceChange, BlockAccessListExpectation, + ChainConfig, + Fork, Op, StateTestFiller, Storage, @@ -395,6 +397,64 @@ def test_unrecoverable_signature( ) +@pytest.mark.inclusion_test +@pytest.mark.valid_from("SpuriousDragon") +@pytest.mark.exception_test +@pytest.mark.eels_base_coverage +@pytest.mark.parametrize( + "case", + [ + "largest_64_bit_chain_id", + "smallest_65_bit_chain_id", + "chain_id_plus_2_64", + ], +) +def test_legacy_chain_id_over_64_bits( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + chain_config: ChainConfig, + case: str, +) -> None: + """ + A legacy transaction whose v encodes a chain ID too large for 64 bits has + an invalid v, while one that fits is only a chain ID mismatch. + """ + if case == "largest_64_bit_chain_id": + signed_for = encoded_in_v = 2**64 - 1 + error = TransactionException.INVALID_CHAINID + elif case == "smallest_65_bit_chain_id": + signed_for = encoded_in_v = 2**64 + error = TransactionException.INVALID_SIGNATURE_VRS + elif case == "chain_id_plus_2_64": + signed_for = chain_config.chain_id + encoded_in_v = chain_config.chain_id + 2**64 + error = TransactionException.INVALID_SIGNATURE_VRS + else: + raise ValueError(f"Unknown case: {case}") + + to = pre.fund_eoa(0xDEADBEEE) + tx = Transaction( + sender=pre.fund_eoa(), + to=to, + value=1, + chain_id=signed_for, + # Signing up front needs the final gas limit. + gas_limit=fork.transaction_intrinsic_cost_calculator()(), + ).with_signature_and_sender() + tx = tx.copy( + # EIP-155: v = 35 + 2 * chain_id + parity. + v=tx.v + 2 * (encoded_in_v - signed_for), + error=error, + ) + + state_test( + pre=pre, + post={to: Account(balance=0xDEADBEEE)}, + tx=tx, + ) + + @pytest.mark.valid_from("Frontier") @pytest.mark.invalid_tx_not_last @pytest.mark.exception_test From 84bf696dea5dbc5bd9249ce656e9b10cbd3f62be Mon Sep 17 00:00:00 2001 From: fselmo Date: Wed, 14 Oct 2026 12:49:06 -0600 Subject: [PATCH 3/4] refactor(tests): move to appropriate module / fork --- tests/frontier/validation/test_transaction.py | 60 --------------- .../__init__.py | 4 + .../spec.py | 16 ++++ .../test_chain_id.py | 75 +++++++++++++++++++ 4 files changed, 95 insertions(+), 60 deletions(-) create mode 100644 tests/spurious_dragon/eip155_simple_replay_attack_protection/__init__.py create mode 100644 tests/spurious_dragon/eip155_simple_replay_attack_protection/spec.py create mode 100644 tests/spurious_dragon/eip155_simple_replay_attack_protection/test_chain_id.py diff --git a/tests/frontier/validation/test_transaction.py b/tests/frontier/validation/test_transaction.py index c228aabd76b..13ad3e418a2 100644 --- a/tests/frontier/validation/test_transaction.py +++ b/tests/frontier/validation/test_transaction.py @@ -8,8 +8,6 @@ BalBalanceChange, BalNonceChange, BlockAccessListExpectation, - ChainConfig, - Fork, Op, StateTestFiller, Storage, @@ -397,64 +395,6 @@ def test_unrecoverable_signature( ) -@pytest.mark.inclusion_test -@pytest.mark.valid_from("SpuriousDragon") -@pytest.mark.exception_test -@pytest.mark.eels_base_coverage -@pytest.mark.parametrize( - "case", - [ - "largest_64_bit_chain_id", - "smallest_65_bit_chain_id", - "chain_id_plus_2_64", - ], -) -def test_legacy_chain_id_over_64_bits( - state_test: StateTestFiller, - pre: Alloc, - fork: Fork, - chain_config: ChainConfig, - case: str, -) -> None: - """ - A legacy transaction whose v encodes a chain ID too large for 64 bits has - an invalid v, while one that fits is only a chain ID mismatch. - """ - if case == "largest_64_bit_chain_id": - signed_for = encoded_in_v = 2**64 - 1 - error = TransactionException.INVALID_CHAINID - elif case == "smallest_65_bit_chain_id": - signed_for = encoded_in_v = 2**64 - error = TransactionException.INVALID_SIGNATURE_VRS - elif case == "chain_id_plus_2_64": - signed_for = chain_config.chain_id - encoded_in_v = chain_config.chain_id + 2**64 - error = TransactionException.INVALID_SIGNATURE_VRS - else: - raise ValueError(f"Unknown case: {case}") - - to = pre.fund_eoa(0xDEADBEEE) - tx = Transaction( - sender=pre.fund_eoa(), - to=to, - value=1, - chain_id=signed_for, - # Signing up front needs the final gas limit. - gas_limit=fork.transaction_intrinsic_cost_calculator()(), - ).with_signature_and_sender() - tx = tx.copy( - # EIP-155: v = 35 + 2 * chain_id + parity. - v=tx.v + 2 * (encoded_in_v - signed_for), - error=error, - ) - - state_test( - pre=pre, - post={to: Account(balance=0xDEADBEEE)}, - tx=tx, - ) - - @pytest.mark.valid_from("Frontier") @pytest.mark.invalid_tx_not_last @pytest.mark.exception_test diff --git a/tests/spurious_dragon/eip155_simple_replay_attack_protection/__init__.py b/tests/spurious_dragon/eip155_simple_replay_attack_protection/__init__.py new file mode 100644 index 00000000000..4f8ebf401bf --- /dev/null +++ b/tests/spurious_dragon/eip155_simple_replay_attack_protection/__init__.py @@ -0,0 +1,4 @@ +""" +Tests for [EIP-155: Simple replay attack +protection](https://eips.ethereum.org/EIPS/eip-155). +""" diff --git a/tests/spurious_dragon/eip155_simple_replay_attack_protection/spec.py b/tests/spurious_dragon/eip155_simple_replay_attack_protection/spec.py new file mode 100644 index 00000000000..901536d9aba --- /dev/null +++ b/tests/spurious_dragon/eip155_simple_replay_attack_protection/spec.py @@ -0,0 +1,16 @@ +"""Defines EIP-155 specification constants and functions.""" + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ReferenceSpec: + """Defines the reference spec version and git path.""" + + git_path: str + version: str + + +ref_spec_155 = ReferenceSpec( + "EIPS/eip-155.md", "17024fa653e75d93f895632e96b20766569cebbd" +) diff --git a/tests/spurious_dragon/eip155_simple_replay_attack_protection/test_chain_id.py b/tests/spurious_dragon/eip155_simple_replay_attack_protection/test_chain_id.py new file mode 100644 index 00000000000..8d46fde7737 --- /dev/null +++ b/tests/spurious_dragon/eip155_simple_replay_attack_protection/test_chain_id.py @@ -0,0 +1,75 @@ +"""Tests for the chain ID a legacy transaction encodes in its v value.""" + +import pytest +from execution_testing import ( + Account, + Alloc, + ChainConfig, + Fork, + StateTestFiller, + Transaction, + TransactionException, +) + +from .spec import ref_spec_155 + +REFERENCE_SPEC_GIT_PATH = ref_spec_155.git_path +REFERENCE_SPEC_VERSION = ref_spec_155.version + + +@pytest.mark.inclusion_test +@pytest.mark.valid_from("SpuriousDragon") +@pytest.mark.exception_test +@pytest.mark.eels_base_coverage +@pytest.mark.parametrize( + "case", + [ + "largest_64_bit_chain_id", + "smallest_65_bit_chain_id", + "chain_id_plus_2_64", + ], +) +def test_legacy_chain_id_over_64_bits( + state_test: StateTestFiller, + pre: Alloc, + fork: Fork, + chain_config: ChainConfig, + case: str, +) -> None: + """ + A legacy transaction whose v encodes a chain ID too large for 64 bits has + an invalid v, while one that fits is only a chain ID mismatch. + """ + if case == "largest_64_bit_chain_id": + signed_for = encoded_in_v = 2**64 - 1 + error = TransactionException.INVALID_CHAINID + elif case == "smallest_65_bit_chain_id": + signed_for = encoded_in_v = 2**64 + error = TransactionException.INVALID_SIGNATURE_VRS + elif case == "chain_id_plus_2_64": + signed_for = chain_config.chain_id + encoded_in_v = chain_config.chain_id + 2**64 + error = TransactionException.INVALID_SIGNATURE_VRS + else: + raise ValueError(f"Unknown case: {case}") + + to = pre.fund_eoa(0xDEADBEEE) + tx = Transaction( + sender=pre.fund_eoa(), + to=to, + value=1, + chain_id=signed_for, + # Signing up front needs the final gas limit. + gas_limit=fork.transaction_intrinsic_cost_calculator()(), + ).with_signature_and_sender() + tx = tx.copy( + # EIP-155: v = 35 + 2 * chain_id + parity. + v=tx.v + 2 * (encoded_in_v - signed_for), + error=error, + ) + + state_test( + pre=pre, + post={to: Account(balance=0xDEADBEEE)}, + tx=tx, + ) From eaed1caa68c2d563c11dff0e9b43d9e60dbd2eed Mon Sep 17 00:00:00 2001 From: fselmo Date: Wed, 14 Oct 2026 13:30:33 -0600 Subject: [PATCH 4/4] fix(test-client-clis): map nethermind and nimbus oversized v errors Both clients read a legacy v as a 64-bit integer and reject a larger one while decoding the payload, so map those decode errors to INVALID_SIGNATURE_VRS. Co-Authored-By: Claude Opus 5.5 --- .../src/execution_testing/client_clis/clis/nethermind.py | 5 ++++- .../testing/src/execution_testing/client_clis/clis/nimbus.py | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/testing/src/execution_testing/client_clis/clis/nethermind.py b/packages/testing/src/execution_testing/client_clis/clis/nethermind.py index 4218be421b4..7a6671e9050 100644 --- a/packages/testing/src/execution_testing/client_clis/clis/nethermind.py +++ b/packages/testing/src/execution_testing/client_clis/clis/nethermind.py @@ -429,9 +429,12 @@ class NethermindExceptionMapper(ExceptionMapper): } mapping_regex = { # In-range r that is not an x-coordinate on the curve leaves the - # transaction without a recovered sender. + # transaction without a recovered sender. Nethermind decodes a legacy + # v as a 64-bit integer, so a v past that fails RLP decoding. TransactionException.INVALID_SIGNATURE_VRS: ( r"failed with error sender not specified" + r"|Transaction \d+ is not valid: " + r"Unexpected length of integer value" ), TransactionException.INSUFFICIENT_ACCOUNT_FUNDS: ( r"insufficient sender balance|" diff --git a/packages/testing/src/execution_testing/client_clis/clis/nimbus.py b/packages/testing/src/execution_testing/client_clis/clis/nimbus.py index aa87070b3cd..f4657577d3f 100644 --- a/packages/testing/src/execution_testing/client_clis/clis/nimbus.py +++ b/packages/testing/src/execution_testing/client_clis/clis/nimbus.py @@ -118,6 +118,11 @@ class NimbusExceptionMapper(ExceptionMapper): TransactionException.INITCODE_SIZE_EXCEEDED: ( "max initcode size exceeded" ), + # Nimbus decodes a legacy v as a 64-bit integer, so a v past that + # fails decoding the payload. + TransactionException.INVALID_SIGNATURE_VRS: ( + "Failed to decode block in payload: integer out of bounds" + ), BlockException.RLP_BLOCK_LIMIT_EXCEEDED: ( # TODO: "ExceededBlockSizeLimit: Exceeded block size limit"