Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions crates/networking/rpc/debug/bad_blocks.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
use ethrex_common::H256;
use ethrex_rlp::encode::RLPEncode;
use serde::Serialize;
use serde_json::Value;
use tracing::debug;

use crate::{RpcApiContext, RpcErr, RpcHandler, types::block::RpcBlock};

pub struct GetBadBlocksRequest;

/// A single entry returned by `debug_getBadBlocks`, mirroring geth's `BadBlockArgs`.
#[derive(Serialize)]
struct BadBlock {
hash: H256,
block: RpcBlock,
rlp: String,
}

impl RpcHandler for GetBadBlocksRequest {
fn parse(_params: &Option<Vec<Value>>) -> Result<Self, RpcErr> {
Ok(GetBadBlocksRequest)
}

async fn handle(&self, context: RpcApiContext) -> Result<Value, RpcErr> {
debug!("Requested bad blocks");

let bad_blocks = context.storage.get_bad_blocks().await?;
let mut results = Vec::with_capacity(bad_blocks.len());
for block in bad_blocks {
let hash = block.hash();
let rlp = format!("0x{}", hex::encode(block.encode_to_vec()));
let rpc_block = RpcBlock::build(block.header, block.body, hash, true)?;
results.push(BadBlock {
hash,
block: rpc_block,
rlp,
});
Comment on lines +29 to +37

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Single failing bad block silences the entire endpoint

RpcBlock::build(…)? propagates any conversion error with ?, aborting the loop immediately. If even one stored bad block produces an RpcBlock build failure (e.g., because of a field that RpcBlock cannot represent, or a future schema change), the whole debug_getBadBlocks response becomes an internal error and all other stored bad blocks are invisible to the caller. Consider skipping the failing entry with a warn! log (matching geth's best-effort approach) so a single corrupt entry cannot break the endpoint for all callers.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/networking/rpc/debug/bad_blocks.rs
Line: 29-37

Comment:
**Single failing bad block silences the entire endpoint**

`RpcBlock::build(…)?` propagates any conversion error with `?`, aborting the loop immediately. If even one stored bad block produces an `RpcBlock` build failure (e.g., because of a field that `RpcBlock` cannot represent, or a future schema change), the whole `debug_getBadBlocks` response becomes an internal error and all other stored bad blocks are invisible to the caller. Consider skipping the failing entry with a `warn!` log (matching geth's best-effort approach) so a single corrupt entry cannot break the endpoint for all callers.

How can I resolve this? If you propose a fix, please make it concise.

}

serde_json::to_value(results).map_err(|error| RpcErr::Internal(error.to_string()))
}
}
1 change: 1 addition & 0 deletions crates/networking/rpc/debug/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod bad_blocks;
pub mod chain_config;
pub mod execution_witness;
pub mod execution_witness_by_hash;
7 changes: 7 additions & 0 deletions crates/networking/rpc/engine/payload.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1239,6 +1239,11 @@ async fn try_execute_payload(
// Execute and store the block
debug!(%block_hash, %block_number, "Executing payload");

// Retain a copy so we can record it via `debug_getBadBlocks` if it turns out
// to be invalid. `add_block` consumes the block, so we must clone beforehand;
// this happens once per newPayload and is negligible next to block execution.
let bad_block_candidate = block.clone();

match add_block(context, block, bal, make_witness).await {
Err(ChainError::ParentNotFound) => {
// Start sync
Expand All @@ -1260,6 +1265,7 @@ async fn try_execute_payload(
.storage
.set_latest_valid_ancestor(block_hash, latest_valid_hash)
.await?;
context.storage.add_bad_block(bad_block_candidate).await?;
Ok(PayloadStatus::invalid_with(
latest_valid_hash,
error.to_string(),
Expand All @@ -1271,6 +1277,7 @@ async fn try_execute_payload(
.storage
.set_latest_valid_ancestor(block_hash, latest_valid_hash)
.await?;
context.storage.add_bad_block(bad_block_candidate).await?;
Ok(PayloadStatus::invalid_with(
latest_valid_hash,
error.to_string(),
Expand Down
2 changes: 2 additions & 0 deletions crates/networking/rpc/rpc.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use crate::authentication::authenticate;
use crate::debug::bad_blocks::GetBadBlocksRequest;
use crate::debug::chain_config::ChainConfigRequest;
use crate::debug::execution_witness::ExecutionWitnessRequest;
use crate::debug::execution_witness_by_hash::ExecutionWitnessByBlockHashRequest;
Expand Down Expand Up @@ -1165,6 +1166,7 @@ pub async fn map_debug_requests(req: &RpcRequest, context: RpcApiContext) -> Res
ExecutionWitnessByBlockHashRequest::call(req, context).await
}
"debug_chainConfig" => ChainConfigRequest::call(req, context).await,
"debug_getBadBlocks" => GetBadBlocksRequest::call(req, context).await,
"debug_traceTransaction" => TraceTransactionRequest::call(req, context).await,
"debug_traceBlockByNumber" => TraceBlockByNumberRequest::call(req, context).await,
"debug_traceBlockByHash" => TraceBlockByHashRequest::call(req, context).await,
Expand Down
9 changes: 8 additions & 1 deletion crates/storage/api/tables.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,13 @@ pub const EXECUTION_WITNESSES: &str = "execution_witnesses";
/// - [`Vec<u8>`] = RLP-encoded `BlockAccessList`
pub const BLOCK_ACCESS_LISTS: &str = "block_access_lists";

pub const TABLES: [&str; 20] = [
/// Bad blocks column family: single-keyed list of the most recent bad blocks
/// seen by the client, served by `debug_getBadBlocks`.
/// - [`Vec<u8>`] = [`BAD_BLOCKS_KEY`]
/// - [`Vec<u8>`] = RLP-encoded `Vec<Block>` (sorted by descending block number)
pub const BAD_BLOCKS: &str = "bad_blocks";

pub const TABLES: [&str; 21] = [
CHAIN_DATA,
ACCOUNT_CODES,
ACCOUNT_CODE_METADATA,
Expand All @@ -134,4 +140,5 @@ pub const TABLES: [&str; 20] = [
MISC_VALUES,
EXECUTION_WITNESSES,
BLOCK_ACCESS_LISTS,
BAD_BLOCKS,
];
48 changes: 44 additions & 4 deletions crates/storage/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ use crate::{
StorageBackend, StorageReadView, StorageWriteBatch,
tables::{
ACCOUNT_CODE_METADATA, ACCOUNT_CODES, ACCOUNT_FLATKEYVALUE, ACCOUNT_TRIE_NODES,
BLOCK_ACCESS_LISTS, BLOCK_NUMBERS, BODIES, CANONICAL_BLOCK_HASHES, CHAIN_DATA,
EXECUTION_WITNESSES, FULLSYNC_HEADERS, HEADERS, INVALID_CHAINS, MISC_VALUES,
PENDING_BLOCKS, RECEIPTS_V2, SNAP_STATE, STORAGE_FLATKEYVALUE, STORAGE_TRIE_NODES,
TRANSACTION_LOCATIONS,
BAD_BLOCKS, BLOCK_ACCESS_LISTS, BLOCK_NUMBERS, BODIES, CANONICAL_BLOCK_HASHES,
CHAIN_DATA, EXECUTION_WITNESSES, FULLSYNC_HEADERS, HEADERS, INVALID_CHAINS,
MISC_VALUES, PENDING_BLOCKS, RECEIPTS_V2, SNAP_STATE, STORAGE_FLATKEYVALUE,
STORAGE_TRIE_NODES, TRANSACTION_LOCATIONS,
},
},
apply_prefix,
Expand Down Expand Up @@ -117,6 +117,12 @@ const CODE_CACHE_MAX_SIZE: u64 = 64 * 1024 * 1024;
/// Key used to persist the `flushed_upto` block number in `MISC_VALUES`.
const FLUSHED_UPTO_KEY: &[u8] = b"bodies_flushed_upto";

/// Single key under which the bounded list of bad blocks is stored in `BAD_BLOCKS`.
const BAD_BLOCKS_KEY: &[u8] = b"bad_blocks";

/// Maximum number of bad blocks retained for `debug_getBadBlocks`.
const MAX_BAD_BLOCKS: usize = 16;

#[derive(Debug)]
struct CodeCache {
inner_cache: LruCache<H256, Code, FxBuildHasher>,
Expand Down Expand Up @@ -1314,6 +1320,40 @@ impl Store {
.map_err(StoreError::from)
}

/// Records a block that failed validation so it can be served by
/// `debug_getBadBlocks`. The list is bounded to [`MAX_BAD_BLOCKS`] entries,
/// kept sorted by descending block number, with the oldest dropped once the
/// bound is exceeded. Duplicate `(number, hash)` entries are ignored.
pub async fn add_bad_block(&self, block: Block) -> Result<(), StoreError> {
let mut bad_blocks = self.get_bad_blocks().await?;
let block_number = block.header.number;
let block_hash = block.hash();
if bad_blocks
.iter()
.any(|b| b.header.number == block_number && b.hash() == block_hash)
Comment on lines +1331 to +1333

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The deduplication check calls b.hash() (a keccak256 over the full block header) on every element of the stored list on each add_bad_block invocation. For a list capped at 16 entries this is acceptable, but the work is avoidable: the caller already has block_hash in scope — storing it alongside the block (e.g., as a (Block, H256) tuple) would let you dedup with a simple equality comparison.

Suggested change
if bad_blocks
.iter()
.any(|b| b.header.number == block_number && b.hash() == block_hash)
// Consider storing the hash alongside each block to avoid re-hashing here.
if bad_blocks
.iter()
.any(|b| b.header.number == block_number && b.hash() == block_hash)
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/storage/store.rs
Line: 1331-1333

Comment:
The deduplication check calls `b.hash()` (a keccak256 over the full block header) on every element of the stored list on each `add_bad_block` invocation. For a list capped at 16 entries this is acceptable, but the work is avoidable: the caller already has `block_hash` in scope — storing it alongside the block (e.g., as a `(Block, H256)` tuple) would let you dedup with a simple equality comparison.

```suggestion
        // Consider storing the hash alongside each block to avoid re-hashing here.
        if bad_blocks
            .iter()
            .any(|b| b.header.number == block_number && b.hash() == block_hash)
```

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

{
return Ok(());
}
bad_blocks.push(block);
bad_blocks.sort_by(|a, b| b.header.number.cmp(&a.header.number));
bad_blocks.truncate(MAX_BAD_BLOCKS);
self.write_async(
BAD_BLOCKS,
BAD_BLOCKS_KEY.to_vec(),
bad_blocks.encode_to_vec(),
)
.await
}
Comment on lines +1327 to +1346

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Non-atomic read-modify-write race condition

add_bad_block reads the current list, mutates it in memory, then writes it back — all without any mutual exclusion. If two engine_newPayload calls arrive concurrently for different invalid blocks, both tasks can read the same initial list, produce diverging in-memory mutations, and the last write_async wins, silently dropping one entry. The scenario is uncommon in practice (CLs usually serialise newPayload) but is a real data-loss path for any concurrent caller. A tokio Mutex guarding the read-modify-write cycle (or wrapping the entire operation in a storage-level transaction if the backend supports it) would close the window.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/storage/store.rs
Line: 1327-1346

Comment:
**Non-atomic read-modify-write race condition**

`add_bad_block` reads the current list, mutates it in memory, then writes it back — all without any mutual exclusion. If two `engine_newPayload` calls arrive concurrently for different invalid blocks, both tasks can read the same initial list, produce diverging in-memory mutations, and the last `write_async` wins, silently dropping one entry. The scenario is uncommon in practice (CLs usually serialise newPayload) but is a real data-loss path for any concurrent caller. A tokio `Mutex` guarding the read-modify-write cycle (or wrapping the entire operation in a storage-level transaction if the backend supports it) would close the window.

How can I resolve this? If you propose a fix, please make it concise.


/// Returns the recent bad blocks seen by the client, sorted by descending
/// block number. Used by `debug_getBadBlocks`.
pub async fn get_bad_blocks(&self) -> Result<Vec<Block>, StoreError> {
match self.read_async(BAD_BLOCKS, BAD_BLOCKS_KEY.to_vec()).await? {
Some(bytes) => Vec::<Block>::decode(&bytes).map_err(StoreError::from),
None => Ok(Vec::new()),
}
}

/// Obtain block number for a given hash
pub fn get_block_number_sync(
&self,
Expand Down
45 changes: 43 additions & 2 deletions test/tests/storage/store_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ use ethrex_common::{
Address, Bloom, H160,
constants::{EMPTY_KECCAK_HASH, EMPTY_TRIE_HASH},
types::{
AccountState, BlockBody, BlockHeader, ChainConfig, Code, Genesis, Receipt, Transaction,
TxType,
AccountState, Block, BlockBody, BlockHeader, ChainConfig, Code, Genesis, Receipt,
Transaction, TxType,
},
utils::keccak,
};
Expand Down Expand Up @@ -56,6 +56,7 @@ async fn test_store_suite(engine_type: EngineType) {
run_test(test_genesis_block, engine_type).await;
run_test(test_iter_accounts, engine_type).await;
run_test(test_iter_storage, engine_type).await;
run_test(test_bad_blocks, engine_type).await;
}

async fn test_iter_accounts(store: Store) {
Expand Down Expand Up @@ -234,6 +235,46 @@ async fn test_store_block(store: Store) {
assert_eq!(stored_body, block_body);
}

async fn test_bad_blocks(store: Store) {
// Empty store returns no bad blocks.
assert!(store.get_bad_blocks().await.unwrap().is_empty());

let (_, body) = create_block_for_testing();
let make_block = |number: u64| {
let header = BlockHeader {
number,
..Default::default()
};
Block::new(header, body.clone())
};

// Insert out of order; the list must come back sorted by descending number.
let block_a = make_block(5);
let block_b = make_block(9);
let block_c = make_block(2);
store.add_bad_block(block_a.clone()).await.unwrap();
store.add_bad_block(block_b.clone()).await.unwrap();
store.add_bad_block(block_c.clone()).await.unwrap();

let bad_blocks = store.get_bad_blocks().await.unwrap();
let numbers: Vec<u64> = bad_blocks.iter().map(|b| b.header.number).collect();
assert_eq!(numbers, vec![9, 5, 2]);

// Duplicate (number, hash) is ignored.
store.add_bad_block(block_b.clone()).await.unwrap();
assert_eq!(store.get_bad_blocks().await.unwrap().len(), 3);

// The list is bounded: inserting many more evicts the lowest-numbered blocks.
for number in 100..140u64 {
store.add_bad_block(make_block(number)).await.unwrap();
}
let bounded = store.get_bad_blocks().await.unwrap();
assert_eq!(bounded.len(), 16);
// Highest kept is the most recent insert; lowest kept is above the evicted range.
assert_eq!(bounded.first().unwrap().header.number, 139);
assert_eq!(bounded.last().unwrap().header.number, 124);
}

fn create_block_for_testing() -> (BlockHeader, BlockBody) {
let block_header = BlockHeader {
parent_hash: H256::from_str(
Expand Down
Loading