From d4c011605b5cc0646784c4730c5ec4a87528875e Mon Sep 17 00:00:00 2001 From: ilitteri Date: Mon, 24 Aug 2026 16:56:10 -0300 Subject: [PATCH] Report a block whose receipts are absent instead of answering empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_receipts_for_block returns a bare Vec, so "this block's receipts are not stored" and "this block had no transactions" are the same value. get_all_block_receipts passed that straight through, so debug_getRawReceipts answered an empty list for a block that has transactions — a wrong answer rather than a reported failure. Check the receipt count against the block's own transaction count, and report a block whose body is absent rather than validating against nothing. This mirrors the mismatch check the by-index receipt path already performs. Genesis keeps its existing short circuit, since it legitimately has none. This matters ahead of history pruning (#6673): an absent receipt set stops being a corruption signal and becomes a normal steady-state outcome, so every path that conflates it with emptiness starts returning wrong answers on every node rather than on a corrupted one. --- crates/networking/rpc/eth/block.rs | 24 ++++++- test/tests/rpc/mod.rs | 1 + .../rpc/raw_receipts_completeness_tests.rs | 63 +++++++++++++++++++ 3 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 test/tests/rpc/raw_receipts_completeness_tests.rs diff --git a/crates/networking/rpc/eth/block.rs b/crates/networking/rpc/eth/block.rs index 68a1d5538fc..7b4dc0edba4 100644 --- a/crates/networking/rpc/eth/block.rs +++ b/crates/networking/rpc/eth/block.rs @@ -453,5 +453,27 @@ pub async fn get_all_block_receipts( return Ok(Vec::new()); } let block_hash = header.hash(); - Ok(storage.get_receipts_for_block(&block_hash).await?) + let receipts = storage.get_receipts_for_block(&block_hash).await?; + // `get_receipts_for_block` returns a bare Vec, so a block whose receipts are + // absent is indistinguishable from a block that genuinely has none. Returning + // the empty list would be a wrong answer rather than a reported failure, so + // check it against the block's own transaction count. This mirrors the + // mismatch check the by-index receipt path already performs. + let expected = match storage.get_block_body_by_hash(block_hash).await? { + Some(body) => body.transactions.len(), + // No body means the block's history is not retained; without it there is + // nothing to validate the receipt count against. + None => { + return Err(RpcErr::Internal(format!( + "Body unavailable for block {block_hash:#x}, cannot serve its receipts" + ))); + } + }; + if receipts.len() != expected { + return Err(RpcErr::Internal(format!( + "Expected {expected} receipts for block {block_hash:#x}, got {}", + receipts.len() + ))); + } + Ok(receipts) } diff --git a/test/tests/rpc/mod.rs b/test/tests/rpc/mod.rs index 6bd383bfc01..98d6e6b6cc6 100644 --- a/test/tests/rpc/mod.rs +++ b/test/tests/rpc/mod.rs @@ -6,6 +6,7 @@ mod estimate_gas_tests; mod fork_choice_tests; mod http_batch_tests; mod missing_rpc_methods_tests; +mod raw_receipts_completeness_tests; mod send_raw_transaction_tests; mod subscription_manager_tests; mod trace_call_tests; diff --git a/test/tests/rpc/raw_receipts_completeness_tests.rs b/test/tests/rpc/raw_receipts_completeness_tests.rs new file mode 100644 index 00000000000..1b58f1d0f4d --- /dev/null +++ b/test/tests/rpc/raw_receipts_completeness_tests.rs @@ -0,0 +1,63 @@ +//! `debug_getRawReceipts` must not present a block's receipts as empty when they +//! are merely absent. +//! +//! `get_receipts_for_block` returns a bare `Vec`, so "this block has no receipts +//! stored" and "this block had no transactions" are the same value. Handing back +//! the empty list is a wrong answer rather than a reported failure, and once +//! history pruning lands (#6673) an absent receipt set becomes a normal +//! steady-state outcome rather than a corruption signal. + +use ethrex_rpc::test_utils::{ + add_legacy_tx_blocks, call_http, default_context_with_storage, setup_store, +}; + +#[tokio::test] +async fn raw_receipts_reports_a_block_whose_receipts_are_absent() { + let store = setup_store().await; + // The harness stores blocks and their transactions but no receipts, which is + // exactly the shape a pruned block presents: body present, receipts gone. + add_legacy_tx_blocks(&store, 1, 1).await; + let context = default_context_with_storage(store).await; + + let response = call_http( + context, + r#"{"jsonrpc":"2.0","method":"debug_getRawReceipts","params":["0x1"],"id":1}"#.to_string(), + ) + .await; + + assert!( + response.get("error").is_some(), + "a block with 1 transaction and no stored receipts must report a failure \ + rather than returning an empty list; got {response}" + ); + assert_ne!( + response["result"].as_array().map(|a| a.len()), + Some(0), + "must not answer with an empty receipt list: {response}" + ); +} + +#[tokio::test] +async fn raw_receipts_still_serves_genesis_as_empty() { + let store = setup_store().await; + add_legacy_tx_blocks(&store, 1, 1).await; + let context = default_context_with_storage(store).await; + + // Genesis legitimately has no receipts and is short-circuited before the + // completeness check, so it must keep answering with an empty list. + let response = call_http( + context, + r#"{"jsonrpc":"2.0","method":"debug_getRawReceipts","params":["0x0"],"id":1}"#.to_string(), + ) + .await; + + assert!( + response.get("error").is_none(), + "genesis must not be reported as a failure: {response}" + ); + assert_eq!( + response["result"].as_array().map(|a| a.len()), + Some(0), + "genesis has no receipts: {response}" + ); +}