From e37370eead46f717254818590b83d67c7f0b7253 Mon Sep 17 00:00:00 2001 From: Jochem Brouwer Date: Wed, 5 Aug 2026 02:54:23 +0200 Subject: [PATCH] perf(client): skip rlp check if rlp clearly not large enough --- .../src/execution_testing/specs/blockchain.py | 119 +++++++++++++++++- 1 file changed, 118 insertions(+), 1 deletion(-) diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index 519944255a..7c7067f53d 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -447,6 +447,91 @@ def set_environment(self, env: Environment) -> Environment: return env.copy(**new_env_values) +# Longest RLP length prefix: 0xfb followed by an eight-byte length. +_RLP_LENGTH_PREFIX_MAX = 9 + +# Stands in for "cannot be bounded cheaply", larger than any real block RLP +# limit so the caller measures the exact size instead. +_RLP_SIZE_UNBOUNDED = 1 << 62 + +# Every field that can appear in the RLP body of any transaction type. A +# superset is safe: fields absent from a given type read as ``None`` and are +# charged one byte, so the result stays an upper bound whatever the type is. +_TRANSACTION_RLP_FIELDS = ( + "chain_id", + "nonce", + "gas_price", + "max_priority_fee_per_gas", + "max_fee_per_gas", + "gas_limit", + "to", + "value", + "data", + "access_list", + "max_fee_per_blob_gas", + "blob_versioned_hashes", + "authorization_list", + "v", + "r", + "s", +) + + +def _rlp_size_upper_bound(value: Any) -> int: + """ + Return an upper bound on ``len(rlp(value))`` without encoding *value*. + + Integers and byte strings are sized exactly; lists recurse; models expose + their fields through ``rlp_fields`` or ``to_serializable_list``. Anything + else yields ``_RLP_SIZE_UNBOUNDED`` rather than a guess, so the result can + never under-estimate. + """ + if value is None: + return 1 # encodes as the empty byte string + if isinstance(value, (bytes, bytearray)): + length = len(value) + if length == 1 and value[0] < 0x80: + return 1 + return length + (1 if length <= 55 else _RLP_LENGTH_PREFIX_MAX) + # ``int`` covers bool and the HexNumber/Number aliases; ``__index__`` picks + # up ``ethereum_types`` Uint/U256, which do not subclass int. + if not isinstance(value, int) and hasattr(value, "__index__"): + value = int(value) + if isinstance(value, int): + if value < 0x80: + return 1 + return 1 + (value.bit_length() + 7) // 8 + if isinstance(value, (list, tuple)): + payload = sum(_rlp_size_upper_bound(item) for item in value) + return payload + (1 if payload <= 55 else _RLP_LENGTH_PREFIX_MAX) + rlp_fields = getattr(value, "rlp_fields", None) + if rlp_fields is not None: + return _rlp_size_upper_bound( + [getattr(value, name, None) for name in rlp_fields] + ) + # Withdrawals and friends expose the serialized order directly. + to_serializable_list = getattr(value, "to_serializable_list", None) + if callable(to_serializable_list): + return _rlp_size_upper_bound(to_serializable_list()) + # Unrecognised value: rather than guess a size that might under-estimate, + # return a bound nothing can fit under, so the caller falls back to + # measuring the exact RLP. + return _RLP_SIZE_UNBOUNDED + + +def _transaction_rlp_size_upper_bound(transaction: Transaction) -> int: + """Bound one transaction as it appears in a block's transaction list.""" + payload = _rlp_size_upper_bound( + [getattr(transaction, name, None) for name in _TRANSACTION_RLP_FIELDS] + ) + if transaction.ty > 0: + # A typed transaction is embedded as an opaque byte string holding the + # type byte followed by the payload. + payload += 1 + payload += 1 if payload <= 55 else _RLP_LENGTH_PREFIX_MAX + return payload + + class BuiltBlock(CamelModel): """Model that contains all properties to build a full block or payload.""" @@ -602,6 +687,35 @@ def verify_transactions( transition_tool_exceptions_reliable=transition_tool_exceptions_reliable, ) + def block_rlp_size_upper_bound(self) -> int: + """ + Return an over-estimate of this block's RLP size, without encoding it. + + ``get_block_rlp`` has to build the entire ``FixtureBlock`` first -- a + pydantic model per transaction, receipt and withdrawal, then a + ``model_dump`` of all of them -- which on a benchmark block carrying + tens of thousands of transactions costs more than executing the block. + + The EIP-7934 limit is only ever compared against, so an upper bound is + enough to rule a violation out; the exact size is only needed once the + bound says the limit may actually be reached. + """ + transactions = sum( + _transaction_rlp_size_upper_bound(tx) for tx in self.txs + ) + return ( + _rlp_size_upper_bound(self.header.rlp_encode_list) + + transactions + + _RLP_LENGTH_PREFIX_MAX # the transaction list + + _RLP_LENGTH_PREFIX_MAX # ommers, always empty here + + ( + _rlp_size_upper_bound(self.withdrawals) + if self.withdrawals is not None + else 0 + ) + + _RLP_LENGTH_PREFIX_MAX # the block list itself + ) + def verify_block_exception( self, transition_tool_exceptions_reliable: bool ) -> None: @@ -611,7 +725,10 @@ def verify_block_exception( ) # Verify exceptions that are not caught by the transition tool. fork_block_rlp_size_limit = self.fork.block_rlp_size_limit() - if fork_block_rlp_size_limit is not None: + if ( + fork_block_rlp_size_limit is not None + and self.block_rlp_size_upper_bound() > fork_block_rlp_size_limit + ): rlp_size = len(self.get_block_rlp()) if rlp_size > fork_block_rlp_size_limit: got_exception = BlockExceptionWithMessage(