From eb6e7c08fae54a3a6b93a7b033f718dd49b08e35 Mon Sep 17 00:00:00 2001 From: Matthias Seitz Date: Thu, 3 Sep 2026 23:04:16 +0200 Subject: [PATCH 1/3] perf(node): serve redacted blocks from the sealed header only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `eth_getBlockByNumber`/`eth_getBlockByHash` on the redacted RPC went through `EthBlocks::rpc_block(.., false)`, which loads the recovered block — a block cache hit, or a body read with sender recovery and per-transaction hashing on a miss — then collects every transaction hash and RLP-encodes the whole block to fill `size`. `redact_block` immediately threw all of that away: the transaction list became an empty `BlockTransactions::Hashes`, `redact_header` zeroed `size`, and withdrawals collapsed to an empty default. Header-only requests for old blocks also evicted live traffic from the block cache. The response is now built from `sealed_header_by_id`, converted with the same `RpcConvert` the full path used (size 0, since it is zeroed anyway) and paired with an empty uncle list, an empty transaction list, and a withdrawals field derived from the header's withdrawals root — a block carries withdrawals exactly when the header has that root, so the serialized JSON is unchanged. `pending` is already normalized to `latest` before it reaches this path. `eth_coinbase` used `rpc_block_header`, which loads the recovered block the same way, and now reads the beneficiary from `latest_header`. --- crates/node/src/rpc.rs | 53 ++++++++++++++++++++++++++++-------------- 1 file changed, 35 insertions(+), 18 deletions(-) diff --git a/crates/node/src/rpc.rs b/crates/node/src/rpc.rs index f9214d1eb..a87d00812 100644 --- a/crates/node/src/rpc.rs +++ b/crates/node/src/rpc.rs @@ -33,10 +33,10 @@ use reth_rpc_api::Web3ApiServer; use reth_rpc_builder::EthHandlers; use reth_rpc_eth_api::{ EthApiTypes, EthFilterApiServer, RpcConvert, - helpers::{EthApiSpec, EthBlocks, EthCall, EthFees, EthState, EthTransactions, FullEthApi}, + helpers::{EthApiSpec, EthCall, EthFees, EthState, EthTransactions, FullEthApi}, }; use reth_rpc_eth_types::{EthApiError, logs_utils}; -use reth_storage_api::{BlockNumReader, StateProviderFactory}; +use reth_storage_api::{BlockNumReader, BlockReaderIdExt, StateProviderFactory}; use reth_trie_common::{ExecutionWitnessMode, HashedStorage}; use tempo_alloy::{ TempoNetwork, @@ -739,19 +739,40 @@ impl ZoneRpc where Api: FullEthApi + EthApiTypes + Send + Sync + 'static, { + /// Serve the redacted block from the sealed header alone. + /// + /// Redaction drops the entire body, so loading the recovered block (and evicting the block + /// cache with it) only to hash every transaction and RLP-encode the body for `size` is wasted + /// work. fn block_by_id(&self, id: BlockId) -> BoxFut<'_> { Box::pin(async move { - let block = EthBlocks::rpc_block(&self.eth.api, id, false) - .await - .map_err(internal)?; - - let Some(mut block) = block else { + let Some(header) = self + .eth + .api + .provider() + .sealed_header_by_id(id) + .map_err(internal)? + else { return Ok(raw_null()); }; - redact_block(&mut block); + // A block carries withdrawals exactly when its header has a withdrawals root. + let withdrawals = header.withdrawals_root().map(|_| Default::default()); + // `redact_header` zeroes `size`, so the block's RLP length is never observed. + let mut header = self + .eth + .api + .converter() + .convert_header(header, 0) + .map_err(internal)?; + redact_header(&mut header); - to_raw(&block) + to_raw(&RpcBlock { + header, + uncles: Vec::new(), + transactions: BlockTransactions::Hashes(Vec::new()), + withdrawals, + }) }) } } @@ -819,8 +840,11 @@ where fn coinbase(&self) -> BoxFut<'_> { Box::pin(async move { - let header = EthBlocks::rpc_block_header(&self.eth.api, BlockId::latest()) - .await + let header = self + .eth + .api + .provider() + .latest_header() .map_err(internal)? .ok_or_else(|| JsonRpcError::internal("latest block not found"))?; to_raw(&header.beneficiary()) @@ -1452,13 +1476,6 @@ fn apply_public_fee_policy(request: &mut TempoTransactionRequest) { } } -/// Strip privacy-sensitive fields from a block returned by the redacted RPC. -fn redact_block(block: &mut RpcBlock) { - redact_header(&mut block.header); - block.transactions = BlockTransactions::Hashes(Vec::new()); - block.withdrawals = block.withdrawals.take().map(|_| Default::default()); -} - pub(crate) fn rpc_connection_config(retry_connection_interval: Duration) -> ConnectionConfig { ConnectionConfig::new() .with_max_retries(u32::MAX) From 431928af439788a1314fedf04c4a9ca9c1327589 Mon Sep 17 00:00:00 2001 From: Matthias Seitz Date: Thu, 3 Sep 2026 23:57:57 +0200 Subject: [PATCH 2/3] perf(node): read redacted block header through the eth state cache The header-only path read the sealed header straight from the provider on every request. The requested block is usually cached, since clients poll `latest`, so resolve the block id to a hash and read the header through `EthStateCache::get_header`: it serves the cached header or the header of the cached full block, and only on a miss loads it from the provider on the cache's blocking task and keeps it for the next request. An unknown hash still returns null. --- crates/node/src/rpc.rs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/crates/node/src/rpc.rs b/crates/node/src/rpc.rs index a87d00812..2e91affe3 100644 --- a/crates/node/src/rpc.rs +++ b/crates/node/src/rpc.rs @@ -26,7 +26,8 @@ use eyre::WrapErr; use futures::StreamExt; use jsonrpsee::{RpcModule, core::RpcResult, proc_macros::rpc, types::ErrorObjectOwned}; use reth_evm::{ConfigureEvm as _, execute::Executor as _}; -use reth_provider::{CanonStateSubscriptions, HeaderProvider}; +use reth_primitives_traits::SealedHeader; +use reth_provider::{CanonStateSubscriptions, HeaderProvider, ProviderError}; use reth_revm::{db::State, witness::ExecutionWitnessRecord}; use reth_rpc::{EthFilter, eth::filter::EthFilterError}; use reth_rpc_api::Web3ApiServer; @@ -36,7 +37,7 @@ use reth_rpc_eth_api::{ helpers::{EthApiSpec, EthCall, EthFees, EthState, EthTransactions, FullEthApi}, }; use reth_rpc_eth_types::{EthApiError, logs_utils}; -use reth_storage_api::{BlockNumReader, BlockReaderIdExt, StateProviderFactory}; +use reth_storage_api::{BlockIdReader, BlockNumReader, BlockReaderIdExt, StateProviderFactory}; use reth_trie_common::{ExecutionWitnessMode, HashedStorage}; use tempo_alloy::{ TempoNetwork, @@ -743,18 +744,24 @@ where /// /// Redaction drops the entire body, so loading the recovered block (and evicting the block /// cache with it) only to hash every transaction and RLP-encode the body for `size` is wasted - /// work. + /// work. The header is read through the eth state cache, which serves it from the cached + /// header or the cached full block and only loads it from the provider on a miss. fn block_by_id(&self, id: BlockId) -> BoxFut<'_> { Box::pin(async move { - let Some(header) = self + let Some(hash) = self .eth .api .provider() - .sealed_header_by_id(id) + .block_hash_for_id(id) .map_err(internal)? else { return Ok(raw_null()); }; + let header = match self.eth.api.cache().get_header(hash).await { + Ok(header) => SealedHeader::new(header, hash), + Err(ProviderError::HeaderNotFound(_)) => return Ok(raw_null()), + Err(err) => return Err(internal(err)), + }; // A block carries withdrawals exactly when its header has a withdrawals root. let withdrawals = header.withdrawals_root().map(|_| Default::default()); From d1a2e1f70c4e0f9de912ffdf04a34b6f8ac8682e Mon Sep 17 00:00:00 2001 From: Matthias Seitz Date: Fri, 4 Sep 2026 14:40:01 +0200 Subject: [PATCH 3/3] fix(node): take the redacted block header from the cached block The reth revision main moved to removed the header cache from `EthStateCache`, so read the header off the cached block via `get_maybe_block` and only fall back to the provider on a miss. --- crates/node/src/rpc.rs | 33 ++++++++++++++++++--------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/crates/node/src/rpc.rs b/crates/node/src/rpc.rs index 11dc5a93a..44455a688 100644 --- a/crates/node/src/rpc.rs +++ b/crates/node/src/rpc.rs @@ -26,8 +26,7 @@ use eyre::WrapErr; use futures::StreamExt; use jsonrpsee::{RpcModule, core::RpcResult, proc_macros::rpc, types::ErrorObjectOwned}; use reth_evm::{ConfigureEvm as _, execute::Executor as _}; -use reth_primitives_traits::SealedHeader; -use reth_provider::{CanonStateSubscriptions, HeaderProvider, ProviderError}; +use reth_provider::{CanonStateSubscriptions, HeaderProvider}; use reth_revm::{db::State, witness::ExecutionWitnessRecord}; use reth_rpc::{EthFilter, eth::filter::EthFilterError}; use reth_rpc_api::Web3ApiServer; @@ -753,23 +752,27 @@ where /// /// Redaction drops the entire body, so loading the recovered block (and evicting the block /// cache with it) only to hash every transaction and RLP-encode the body for `size` is wasted - /// work. The header is read through the eth state cache, which serves it from the cached - /// header or the cached full block and only loads it from the provider on a miss. + /// work. The requested block is usually cached, since clients poll `latest`, so the header + /// is taken from the cached block and only read from the provider on a miss. fn block_by_id(&self, id: BlockId) -> BoxFut<'_> { Box::pin(async move { - let Some(hash) = self - .eth - .api - .provider() - .block_hash_for_id(id) - .map_err(internal)? - else { + let provider = self.eth.api.provider(); + let Some(hash) = provider.block_hash_for_id(id).map_err(internal)? else { return Ok(raw_null()); }; - let header = match self.eth.api.cache().get_header(hash).await { - Ok(header) => SealedHeader::new(header, hash), - Err(ProviderError::HeaderNotFound(_)) => return Ok(raw_null()), - Err(err) => return Err(internal(err)), + let cached = self + .eth + .api + .cache() + .get_maybe_block(hash) + .await + .map_err(internal)?; + let header = match cached { + Some(block) => block.clone_sealed_header(), + None => match provider.sealed_header_by_hash(hash).map_err(internal)? { + Some(header) => header, + None => return Ok(raw_null()), + }, }; // A block carries withdrawals exactly when its header has a withdrawals root.