Skip to content

fix(merge): capture bad blocks at pre-process rejection stages#11696

Merged
LukaszRozmej merged 8 commits into
NethermindEth:masterfrom
0xDevNinja:fix/6719-bad-block-capture-pre-processing
Jun 2, 2026
Merged

fix(merge): capture bad blocks at pre-process rejection stages#11696
LukaszRozmej merged 8 commits into
NethermindEth:masterfrom
0xDevNinja:fix/6719-bad-block-capture-pre-processing

Conversation

@0xDevNinja

Copy link
Copy Markdown
Contributor

Closes #6719

Changes

  • Add IBlockTree.ReportBadBlock(Block) — recording-only counterpart to DeleteInvalidBlock. Inserts the block into the in-memory invalid-blocks cache and the bad-block store, but does not reset BestSuggested* or delete from the block store (the rejected block was never inserted there).
  • DeleteInvalidBlock now delegates the recording half to ReportBadBlock so both paths stay in sync.
  • Add NewPayloadHandler.RecordBadBlock — private helper that bumps Metrics.BadBlocks / Metrics.BadBlocksByNethermindNodes, notifies IInvalidChainTracker, and calls _blockTree.ReportBadBlock. Wired into the three pre-process rejection sites in NewPayloadHandler.HandleAsync: hash mismatch, orphaned-block validation, and suggested-block validation.
  • Plumb the new interface method through BlockTreeOverlay, ReadOnlyBlockTree, StatelessBlockTree, and TestBlockTree.

Types of changes

What types of changes does your code introduce?

  • Bugfix (a non-breaking change that fixes an issue)
  • New feature (a non-breaking change that adds functionality)
  • Breaking change (a change that causes existing functionality not to work as expected)
  • Optimization
  • Refactoring
  • Documentation update
  • Build-related changes
  • Other: Description

Testing

Requires testing

  • Yes
  • No

If yes, did you write tests?

  • Yes
  • No

Notes on testing

  • BlockTreeTests.Report_bad_block_stores_block_and_does_not_alter_main_chain — bad-block store receives the block; BestSuggestedHeader unchanged (vs. DeleteInvalidBlock).
  • BlockTreeTests.Report_bad_block_ignores_block_without_hash — null-Hash guard.
  • EngineModuleTests.executePayloadV1_invalid_hash_records_bad_block — end-to-end: an engine_newPayloadV1 call with mismatched BlockHash causes the block to surface in the resolved IBadBlockStore (i.e. would be visible in debug_getBadBlocks).
  • dotnet build src/Nethermind/Nethermind.slnx -c Release — full solution builds clean.
  • Nethermind.Blockchain.Test — 1491 passing / 15 pre-existing skipped / 0 failures.
  • Nethermind.Merge.Plugin.Test — 1016 passing / 2 pre-existing skipped / 0 failures.

Documentation

Requires documentation update

  • Yes
  • No

Requires explanation in Release Notes

  • Yes
  • No

@LukaszRozmej

Copy link
Copy Markdown
Member

@claude review

@claude

claude Bot commented May 20, 2026

Copy link
Copy Markdown
Contributor

Claude finished @LukaszRozmej's task in 6m 28s —— View job


Code Review — fix(merge): capture bad blocks at pre-processing rejection stages

  • Read changed files and gather context
  • Load relevant rules
  • Analyze correctness, edge cases, security, performance
  • Post review findings

Summary

The design is sound. Introducing IBlockTree.ReportBadBlock as a recording-only counterpart to DeleteInvalidBlock, delegating the recording half of DeleteInvalidBlock to it, and wiring the three pre-process rejection sites in NewPayloadHandler through a shared RecordBadBlock helper is clean and correct. The BlockTree unit tests for ReportBadBlock are solid, and the end-to-end test for the hash-mismatch path validates the wiring correctly.

Three findings below; no critical or high severity issues.


Findings

Medium

Missing integration-test coverage for the orphaned-block and suggested-block rejection paths (EngineModuleTests.V1.cs:431)

The PR patches three sites in NewPayloadHandler.HandleAsync: hash mismatch, orphaned-block validation failure, and suggested-block validation failure. Only the hash-mismatch path gets an integration test that asserts IBadBlockStore.GetAll() contains the rejected block. The other two sites share the same RecordBadBlock helper (which is unit-tested indirectly), but if a future refactor accidentally drops one of those RecordBadBlock calls, there is no test to catch it. Consider adding analogous engine_newPayloadV1_*_records_bad_block tests for the other two rejection sites.


Low

Log message typo in BlockTree.ReportBadBlock (BlockTree.cs:799)

The guarded condition is badBlock.Hash is null, but the message reads "block without a null hash" — it should be "block with a null hash". This is a copy of the pre-existing typo in DeleteInvalidBlock (line 811). Both would be worth fixing together.

Inconsistent null handling between RecordBadBlock and ReportBadBlock (NewPayloadHandler.cs:280)

RecordBadBlock calls _invalidChainTracker.OnInvalidBlock(block.Hash!, ...) (null-forgiving) before _blockTree.ReportBadBlock(block) (which guards badBlock.Hash is null). If block.Hash were null, OnInvalidBlock would throw NullReferenceException with metrics already incremented. In practice all three call sites have a non-null hash (the block is fully decoded from the payload before reaching these sites), so this is latent. Adding a guard at the top of RecordBadBlock would make it self-consistent with ReportBadBlock.


Verdict

Severity Count
Critical 0
High 0
Medium 1
Low 2

The Medium finding (missing test coverage for two of three patched sites) should be addressed before merge to ensure regression protection across all the paths the fix targets.

{
if (badBlock.Hash is null)
{
if (Logger.IsWarn) Logger.Warn($"{nameof(ReportBadBlock)} call has been made for a block without a null hash.");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Low — log message typo (copied from DeleteInvalidBlock)

The condition guarded here is badBlock.Hash is null, so "without a null hash" is inverted. Should read "with a null hash" (or "without a hash"). The same typo exists in DeleteInvalidBlock at line 811 — both would be worth fixing together.

Suggested change
if (Logger.IsWarn) Logger.Warn($"{nameof(ReportBadBlock)} call has been made for a block without a null hash.");
if (Logger.IsWarn) Logger.Warn($"{nameof(ReportBadBlock)} call has been made for a block with a null hash.");

Nethermind.Blockchain.Metrics.BadBlocksByNethermindNodes++;
}

_invalidChainTracker.OnInvalidBlock(block.Hash!, block.ParentHash);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Low — inconsistent null handling between RecordBadBlock and ReportBadBlock

_invalidChainTracker.OnInvalidBlock(block.Hash!, ...) uses the null-forgiving operator, but the callee BlockTree.ReportBadBlock has an explicit if (badBlock.Hash is null) guard. If block.Hash were ever null here, OnInvalidBlock would throw NullReferenceException before the guard in ReportBadBlock fires, and the metrics would have already been incremented.

In practice all three call sites arrive after TryGetBlock succeeds and the block header has Hash = BlockHash set explicitly (see ExecutionPayload.TryGetBlock), so block.Hash is never null at these sites. Still, consider adding a guard or calling ReportBadBlock first (letting it absorb the null case) and only proceeding to the other calls when it succeeds — or add a null-check consistent with ReportBadBlock:

private void RecordBadBlock(Block block)
{
    if (block.Hash is null) return;   // mirrors BlockTree.ReportBadBlock guard
    Nethermind.Blockchain.Metrics.BadBlocks++;
    ...
}

IBadBlockStore badBlockStore = chain.Container.Resolve<IBadBlockStore>();
badBlockStore.GetAll().Should().ContainSingle()
.Which.Number.Should().Be(blockNumber);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Medium — missing integration-test coverage for two of the three patched rejection sites

This test verifies the hash-mismatch path. The PR also wires RecordBadBlock into the orphaned-block validation failure path (line ~163 in NewPayloadHandler.cs) and the suggested-block validation failure path (line ~198). Neither has a corresponding integration test that asserts IBadBlockStore.GetAll() contains the rejected block.

Because all three call sites share the same RecordBadBlock helper, the risk of one being silently disconnected in a future refactor is real. Consider adding:

[Test]
public async Task executePayloadV1_orphan_invalid_records_bad_block()
{
    // submit a structurally-invalid block with unknown parent
    // assert IBadBlockStore.GetAll() contains it
}

[Test]
public async Task executePayloadV1_suggested_block_invalid_records_bad_block()
{
    // submit a payload that fails ValidateSuggestedBlock but has a known parent
    // assert IBadBlockStore.GetAll() contains it
}

@LukaszRozmej

Copy link
Copy Markdown
Member

@claude re-review

@claude

claude Bot commented May 21, 2026

Copy link
Copy Markdown
Contributor

Claude finished @LukaszRozmej's task in 3m 13s —— View job


Re-review — fix(merge): capture bad blocks at pre-processing rejection stages

  • Read changed files
  • Check if previous findings were addressed
  • Analyze for new issues
  • Post review findings

Summary

Two new commits have landed since the last review:

  • fe56564 — fixes the log-wording typo in both ReportBadBlock and DeleteInvalidBlock
  • 68f4af8 — adds executePayloadV1_invalid_orphan_records_bad_block, covering the orphaned-block rejection path

The design remains correct. Of the three findings from the last review, one Low is fully resolved and the Medium is partially resolved. Details below.


Findings — status update

✅ Resolved — Low: log message typo

BlockTree.ReportBadBlock and BlockTree.DeleteInvalidBlock both now read "…for a block with a null hash." — corrected.


⚠️ Medium (partially resolved) — missing integration test for the suggested-block rejection path

NewPayloadHandler.cs:195–200

The orphaned-block path now has a test. The third site — the ValidateSuggestedBlock failure at line 195 (reached when ShouldProcessBlock returns false because the parent's state is unavailable) — still has no integration test asserting that IBadBlockStore.GetAll() returns the block. If a future refactor drops the RecordBadBlock(block) call at line 198, no test will catch it.

The orphan-test pattern used in executePayloadV1_invalid_orphan_records_bad_block is a useful template. The suggested-block path needs ShouldProcessBlock to return false, which happens when the parent header is found but WasProcessed is false (i.e. beacon-sync mode). One approach is to insert a raw header into the tree (so the parent is known) without processing it, then send a payload on top and break a suggested-block invariant.


⚠️ Low (unchanged) — inconsistent null handling in RecordBadBlock

NewPayloadHandler.cs:280

_invalidChainTracker.OnInvalidBlock(block.Hash!, ...) still uses the null-forgiving operator. If block.Hash were null, OnInvalidBlock would throw before ReportBadBlock's guard fires, and metrics would already be incremented. In practice all three call sites guarantee a non-null hash (the block passed HeaderValidator.ValidateHash at line 129), so this is latent. A guard consistent with ReportBadBlock would make RecordBadBlock self-contained:

private void RecordBadBlock(Block block)
{
    if (block.Hash is null) return;
    Nethermind.Blockchain.Metrics.BadBlocks++;
    ...
}

Verdict

Severity Count Status
Critical 0
High 0
Medium 1 Partially resolved — orphan path now covered; suggested-block path still missing
Low 1 Unchanged (null guard)

The remaining Medium item (no regression test for the ValidateSuggestedBlock failure path) should be addressed before merge — the orphan-test commit shows the pattern is straightforward to replicate.

@LukaszRozmej

Copy link
Copy Markdown
Member

@claude re-review

@claude

claude Bot commented May 25, 2026

Copy link
Copy Markdown
Contributor

Claude finished @LukaszRozmej's task in 1m 33s —— View job


Re-review — fix(merge): capture bad blocks at pre-processing rejection stages

  • Read changed files and gather context
  • Check previous findings against new commits
  • Analyze for new issues
  • Post review findings

Summary

Two new commits landed since the last review:

  • 36b826a — adds if (block.Hash is null) return; guard to RecordBadBlock, removes the ! null-forgiving operator from OnInvalidBlock, making RecordBadBlock self-consistent with ReportBadBlock
  • 1c22124 — adds executePayloadV1_invalid_suggested_records_bad_block, covering the third RecordBadBlock site (ValidateSuggestedBlock failure path)

All three findings from prior reviews are now resolved. The test for the suggested-block path correctly routes through the target branch: inserting the parent header via BeaconBlockInsert makes ShouldProcessBlock return false (parent known but unprocessed), and setting Timestamp = parentPayload.Timestamp breaks the timestamp-must-exceed-parent invariant so ValidateSuggestedBlock fails and RecordBadBlock is invoked.


Finding status

# Severity Finding Status
1 Medium Missing integration test for suggested-block rejection path ✅ Resolved — 1c22124
2 Low Inconsistent null handling in RecordBadBlock (block.Hash!) ✅ Resolved — 36b826a
3 Low Log message typo in ReportBadBlock / DeleteInvalidBlock ✅ Resolved — fe56564

Verdict

Severity Count
Critical 0
High 0
Medium 0
Low 0

All previously raised findings are addressed. The PR is correct and complete — ready to merge.

Comment on lines +276 to +280
Nethermind.Blockchain.Metrics.BadBlocks++;
if (block.IsByNethermindNode())
{
Nethermind.Blockchain.Metrics.BadBlocksByNethermindNodes++;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should this be here or in BlockTree?

Introduce a recording-only counterpart to DeleteInvalidBlock: it adds
the block to the invalid-blocks cache and the bad-block store but does
not touch BestSuggested* or delete from the block store. This lets
callers that reject a block before insertion (so there is nothing to
delete from the chain) still surface it via debug_getBadBlocks.

DeleteInvalidBlock now delegates to ReportBadBlock for the recording
half so both paths stay in sync.
NewPayloadHandler can reject a block at three checks that run before
BranchProcessor ever sees it (hash mismatch, orphaned-block validation,
suggested-block validation). Until now those rejections only logged a
warning: they did not bump the bad-block metrics, did not teach the
invalid-chain tracker, and did not write to the bad-block store, so the
block was invisible to debug_getBadBlocks.

Route all three sites through a shared RecordBadBlock helper that mirrors
the bookkeeping BlockchainProcessor performs when it catches an
InvalidBlockException.

Closes NethermindEth#6719
…teInvalidBlock

Both guard clauses fire when `block.Hash is null` but logged "without a null hash",
which inverts the condition. Use "with a null hash" so operators see the actual
state when these branches are exercised.
Builds on the hash-mismatch coverage by exercising the orphan rejection
branch: an unknown parent hash forces `NewPayloadHandler` down the
`ValidateOrphanedBlock` path, and a corrupted `GasUsed > GasLimit` makes
that validation fail. The new test asserts the rejected block lands in
`IBadBlockStore`, verifying the second of the three RecordBadBlock sites
introduced in this PR.
`RecordBadBlock` used `block.Hash!` while the downstream `ReportBadBlock`
guards `badBlock.Hash is null`. If the hash were ever null, the
`OnInvalidBlock` call would NRE after metrics had already been bumped.
Add an early return mirroring `ReportBadBlock` so the helper is
self-consistent and the metrics are not advanced for an unrecordable
block.
Covers the third `RecordBadBlock` site in `NewPayloadHandler` — the
`ValidateSuggestedBlock` failure reached when `ShouldProcessBlock`
returns `false` because the parent is known but unprocessed (beacon-sync
mode). Inserts a parent header via `BeaconBlockInsert` so it is found
but never processed, then submits a child with `Timestamp <=
parent.Timestamp` to break a parent-relative header invariant. Without
the recording call at this site the assertion on `IBadBlockStore.GetAll`
would fail.
@0xDevNinja 0xDevNinja force-pushed the fix/6719-bad-block-capture-pre-processing branch from 1c22124 to 82a6824 Compare May 27, 2026 07:26
…capture-pre-processing

# Conflicts:
#	src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs
@svlachakis

Copy link
Copy Markdown
Contributor

@claude review the following review

🔴 CRITICAL

C1 — _latestBlocks LRU cache not populated at pre-process rejection sites
NewPayloadHandler.ValidateBlockAndProcess consults _latestBlocks at the top to short-circuit known-invalid blocks. The three new
RecordBadBlock call sites (hash mismatch, orphan invariant, validator failure) write to BadBlockStore and _invalidBlocks but skip
_latestBlocks. A re-submission of the same bad payload pays the full revalidation cost instead of returning Invalid from the cache. Fix:
insert _latestBlocks[block.Hash] = (false, errorMessage) inside RecordBadBlock.

C2 — BlockTree.DeleteInvalidBlock can null-deref after the delegation refactor
The refactor delegates the null guard to ReportBadBlock (which returns early on badBlock.Hash is null). But DeleteInvalidBlock keeps going
past the delegate call to DeleteBlocks(invalidBlock.Hash!) — the ! will throw NullReferenceException if ever reached with a null hash. The
old code had its own guard; the new code relies on a guard inside a void callee. Fix: keep the explicit null guard in DeleteInvalidBlock,
or make ReportBadBlock return bool so the caller can short-circuit.


🟠 WARNING

W1 — InvalidChainTracker.OnInvalidBlock called on unvalidated parent hash
At the hash-mismatch site, block.ParentHash comes from a payload whose BlockHash was tampered. Calling
_invalidChainTracker.OnInvalidBlock(block.Hash!, block.ParentHash) poisons the tracker's invalid-chain graph for a parent the node hasn't
validated. If OnInvalidBlock isn't idempotent against a parent that later appears in a valid block, a malicious CL could poison legitimate
parent hashes. Audit InvalidChainTracker.OnInvalidBlock: if it isn't idempotent on parent reuse, drop the call at the pre-process sites and
leave only the BadBlockStore write — that's what the spec actually requires for INVALID/lastValidHash=null.

W2 — ReadOnlyBlockTree.ReportBadBlock throws InvalidOperationException
Wrong exception type for a structurally-unsupported operation. InvalidOperationException means "not valid in current state"; the correct
signal here is NotSupportedException, matching StatelessBlockTree.ReportBadBlock and the rest of ReadOnlyBlockTree's mutation guards (e.g.
UpdateHeadBlock).

W3 — Inline Nethermind.Blockchain.Metrics.BadBlocks++ fully-qualified
The fully-qualified name in RecordBadBlock exists to disambiguate from another Metrics in scope. Add a using BlockchainMetrics =
Nethermind.Blockchain.Metrics; alias and use BlockchainMetrics.BadBlocks++ / BlockchainMetrics.BadBlocksByNethermindNodes++. Avoids two
lines of noise on the hot path and follows the project's import discipline.

W4 — Orphan-block test fragility
executePayloadV1_invalid_orphan_records_bad_block triggers ValidateOrphanedBlock by setting GasUsed > GasLimit. If a future validator
change rejects that condition earlier, the test silently stops exercising the orphan path while still asserting PayloadStatus.Invalid. Name
the specific validator rule in a comment so future readers don't "fix" the payload back into validity.


🟡 SUGGESTION

S1 — Pass the error message into RecordBadBlock(block, message)
Every call site does:
if (_logger.IsWarn) _logger.Warn(InvalidBlockHelper.GetMessage(block, "..."));
RecordBadBlock(block);
return NewPayloadV1Result.Invalid(...);
Threading the message through RecordBadBlock lets it log once internally and collapses the call site to a single line. Eliminates the
duplicate GetMessage string construction.

S2 — Test names don't follow project convention
{WhatUnderTest}{AtWhatState}{ExpectedOutcome} — rename:

  • Report_bad_block_stores_block_and_does_not_alter_main_chain → ReportBadBlock_WhenBlockHasHash_StoresWithoutAlteringMainChain
  • Report_bad_block_ignores_block_without_hash → ReportBadBlock_WhenBlockHashIsNull_DoesNothing
  • executePayloadV1_invalid_hash_records_bad_block → ExecutePayloadV1_WhenBlockHashMismatch_RecordsBadBlock

S3 — Hot-path I/O risk in BadBlockStore.Insert
RecordBadBlock is now reachable from every pre-process Engine API rejection. Under adversarial CL traffic, frequent synchronous disk writes
via IBadBlockStore.Insert add latency to the Engine API thread. Confirm IBadBlockStore is async / write-behind, or route through the same
background channel BlockchainProcessor uses for its own bad-block recording.

@claude

claude Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Claude finished @svlachakis's task in 5m 11s —— View job


Review of submitted findings

  • Read relevant source files
  • Verify C1 — _latestBlocks LRU cache not populated at pre-process rejection sites
  • Verify C2 — BlockTree.DeleteInvalidBlock null-deref after delegation refactor
  • Verify W1 — InvalidChainTracker.OnInvalidBlock on unvalidated parent hash
  • Verify W2 — ReadOnlyBlockTree.ReportBadBlock throws wrong exception type
  • Verify W3 — Fully-qualified Metrics in RecordBadBlock
  • Verify W4 — Orphan-block test fragility
  • Verify S1–S3 — Suggestions
  • Post findings

Assessment

After reading the relevant source, two of the five critical/warning findings are factually incorrect, one is overstated, and one is imprecise. The suggestions are mostly valid at low severity. Details below.


C1 — _latestBlocks cache gap at pre-process sites

Assessment: Valid concern, but overstated as Critical.

The _latestBlocks cache is checked inside ValidateBlockAndProcess (NewPayloadHandler.cs:353), which is only reached when ShouldProcessBlock returns true (line 245). All three pre-process rejection sites return before ever reaching ValidateBlockAndProcess. Therefore, populating _latestBlocks inside RecordBadBlock would only benefit a re-submission that (a) first fails at a pre-process site, then (b) later reaches ValidateBlockAndProcess on a second call — an uncommon sequence.

Additionally, caching the hash-mismatch case is the most problematic: block.Hash is the claimed (wrong) payload hash, not the real block hash. Since ValidateHash always fires before any cache lookup, re-submissions of a hash-mismatch payload are re-checked by the same cheap SHA-3 operation, not by a full block processing pipeline. The actual performance impact of not caching is negligible for these pre-process checks. The concern is Low, not Critical.


C2 — DeleteInvalidBlock null-deref after refactor

Assessment: Factually incorrect — the null guard is still present.

Looking at BlockTree.cs:808–833:

public void DeleteInvalidBlock(Block invalidBlock)
{
    if (invalidBlock.Hash is null)   // ← guard still present
    {
        if (Logger.IsWarn) Logger.Warn($"...");
        return;
    }
    ...
    ReportBadBlock(invalidBlock);
    ...
    DeleteBlocks(invalidBlock.Hash!);  // safe: guarded above
}

The claim that "the new code relies on a guard inside a void callee" is incorrect. DeleteInvalidBlock retains its own null guard and returns early before both the delegate call and DeleteBlocks. The ! on line 827 is safe. This finding is false.


W1 — OnInvalidBlock poisons parent hash

Assessment: Imprecise — the implementation does not poison parent hashes.

Reading InvalidChainTracker.OnInvalidBlock: the method sets failedBlockNode.LastValidHash = effectiveParent (the parent is the last valid hash, not an invalid one) and propagates that to children of the failed block. It does NOT mark the parent node itself as invalid, nor does it add the parent to any "bad" set.

IsOnKnownInvalidChain(someHash) returns true only if someHash's own node has a LastValidHash set, which only happens if OnInvalidBlock(someHash, …) was called for that exact hash. Calling OnInvalidBlock(badHash, parentHash) will never cause IsOnKnownInvalidChain(parentHash) to return true.

For the hash-mismatch site specifically: block.Hash is the claimed wrong hash. Since ValidateHash checked that this hash ≠ block.CalculateHash(), no legitimate block can share this hash — poisoning it is harmless. The finding overstates the risk. The audit it calls for is reasonable but the vulnerability as described does not exist.


W2 — ReadOnlyBlockTree.ReportBadBlock uses wrong exception type

Assessment: Factually incorrect — InvalidOperationException is the consistent pattern in that class.

Every mutation-guard in ReadOnlyBlockTree throws InvalidOperationException (lines 51, 63, 66, 68, 74, 76, 78, 80, 194, 195, 204, 206, 210, 212). The claim that "the rest of ReadOnlyBlockTree's mutation guards use NotSupportedException" is wrong — that pattern belongs to StatelessBlockTree, a separate class. The new ReportBadBlock on line 107 is consistent with the rest of ReadOnlyBlockTree. This finding is false.


W3 — Fully-qualified Metrics reference

Assessment: Valid style suggestion, but Low severity, not Warning.

Nethermind.Blockchain.Metrics.BadBlocks++ is verbose compared to an alias like using BlockchainMetrics = Nethermind.Blockchain.Metrics;. This is a minor style concern, not a correctness or performance issue.


W4 — Orphan-block test fragility

Assessment: Valid Low-severity observation.

The test relies on GasUsed > GasLimit to trigger ValidateOrphanedBlock failure. A comment naming the specific rule (HeaderValidator's gas-used check) would guard against a future "fix" silently rerouting the rejection. The test comment already explains the why but not the which rule, so this is worth a note.


S1 — Thread message through RecordBadBlock

Assessment: Valid suggestion. The call sites repeat InvalidBlockHelper.GetMessage before calling RecordBadBlock, so threading the message in would reduce duplication.

S2 — Test naming convention

Assessment: Questionable. Existing tests in the same file use the camelCase_verb_subject pattern (e.g. executePayloadV1_rejects_invalid_blockHash). The new tests follow that same convention. The {WhatUnderTest}_{AtWhatState}_{ExpectedOutcome} format with PascalCase is used elsewhere in the codebase but is not universal in EngineModuleTests. Renaming is cosmetic and should follow the local file convention.

S3 — Synchronous I/O on Engine API thread

Assessment: Valid Low concern. BadBlockStore.Insert performs a synchronous RocksDB write. The existing DeleteInvalidBlock path in BlockTree already does this, but that runs on the blockchain processor background thread, not the Engine API handler thread. Under adversarial conditions (RecordBadBlock is now reachable from every pre-process rejection), repeated synchronous writes could add Engine API latency. Bad blocks are rare in production, so this is low risk, but worth noting.


Verdict on the submitted review

Finding Claimed Severity Accurate? Actual Severity
C1 — _latestBlocks not populated Critical Partially Low
C2 — DeleteInvalidBlock null-deref Critical No — guard is still present N/A
W1 — parent hash poisoning Warning Imprecise Low
W2 — wrong exception type Warning No — InvalidOperationException is correct for ReadOnlyBlockTree N/A
W3 — fully-qualified Metrics Warning Yes Low/Suggestion
W4 — orphan test fragility Warning Yes Low
S1–S3 Suggestion Yes Suggestion

Two findings (C2, W2) are based on incorrect premises and should not block the PR. C1 and W1 are valid concerns but at low severity, not critical. The PR itself is in good shape — all previously raised findings from the three review rounds have been addressed.

@svlachakis

Copy link
Copy Markdown
Contributor

@0xDevNinja please check build issues & claude review

…BlockTreeTests

Nethermind.Blockchain.Test does not reference FluentAssertions; rewrite
the new Report_bad_block tests to NUnit Assert.That to match the
existing style in this assembly and unbreak the build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@svlachakis svlachakis force-pushed the fix/6719-bad-block-capture-pre-processing branch from 045135a to 92cf9c1 Compare June 2, 2026 12:25
@LukaszRozmej

Copy link
Copy Markdown
Member

@claude re-review

@claude

claude Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Claude finished @LukaszRozmej's task in 5m 41s —— View job


Re-review — fix(merge): capture bad blocks at pre-processing rejection stages

  • Read changed files and gather context
  • Load relevant rules
  • Check current state vs all prior findings (3 claude rounds + svlachakis manual review)
  • Address open design question (RecordBadBlock placement)
  • Check for build issues / new regressions

What changed since the last review

Two commits landed:

Commit Description
92cf9c1 Fix build: replace FluentAssertions with NUnit Assert.That in the two new Report_bad_block_* tests — Nethermind.Blockchain.Test does not reference FluentAssertions, so those tests failed to compile

That's the build issue @svlachakis flagged. The fix is correct: the new assertions (Assert.That(stored, Has.Length.EqualTo(1)), Assert.That(stored[0].Hash, Is.EqualTo(...)), etc.) match the style used everywhere else in BlockTreeTests.cs.


Prior findings — final status

All findings from the three previous claude review rounds remain fully resolved. No regressions.

Round Finding Status
1 Log message typo in ReportBadBlock / DeleteInvalidBlock ✅ Fixed (fe56564)
1–2 Missing null guard in RecordBadBlock (block.Hash!) ✅ Fixed (b6eb2ef)
1–2 No integration test for orphan rejection path ✅ Fixed (5d3946c)
1–3 No integration test for suggested-block rejection path ✅ Fixed (82a6824)

Assessment of @svlachakis's findings (re-confirmed against current code)

The previous re-review already assessed these; no new commits have changed the relevant code. Verdict stands:

Finding Claimed Accurate Note
C1 — _latestBlocks not populated at pre-process sites Critical Partially Low at most — _latestBlocks is only consulted inside ValidateBlockAndProcess, which is unreachable from pre-process rejection sites; the "re-submission" scenario costs one cheap SHA-3 re-check, not full processing
C2 — DeleteInvalidBlock null-deref Critical No DeleteInvalidBlock retains its own if (invalidBlock.Hash is null) return; guard (line 810); the ! at line 827 is provably safe
W1 — OnInvalidBlock poisons parent hashes Warning Imprecise OnInvalidBlock(bad_hash, parent_hash) marks bad_hash as invalid; the parent node is never marked invalid; no legitimate block can share the wrong claimed hash
W2 — ReadOnlyBlockTree.ReportBadBlock wrong exception Warning No Every mutation guard in ReadOnlyBlockTree throws InvalidOperationException (lines 51, 63, 66, 68, 74, …); NotSupportedException is StatelessBlockTree's pattern, not this class
W3 — fully-qualified Metrics in RecordBadBlock Warning Yes (Low) Minor style; a using alias would be cleaner but is not blocking
W4 — orphan-block test fragility Warning Yes (Low) Adding a comment naming the specific validator rule (GasUsed > GasLimit) would guard against silent rerouting

Open inline comment — "Should this be here or in BlockTree?"

@LukaszRozmej's question at NewPayloadHandler.cs:273–285: whether RecordBadBlock's logic (metrics + OnInvalidBlock + ReportBadBlock) should live in BlockTree instead.

Answer: it belongs in NewPayloadHandler. BlockTree has no dependency on IInvalidChainTracker, and adding one would create an upward dependency violation: Nethermind.Blockchain would gain a compile-time dependency on a Merge-layer concern. The current split is the right factoring:

  • BlockTree.ReportBadBlock — pure storage (bad-block store + in-memory cache)
  • NewPayloadHandler.RecordBadBlock — Engine-API layer orchestration (metrics + chain invalidity tracking + storage)

The precedent is the same split used in BlockchainProcessor: it calls DeleteInvalidBlock (storage) after setting up chain-tracker state externally.


New findings

None. The build fix in 92cf9c1 is correct and complete. All three pre-process rejection paths (hash mismatch, orphan validation, suggested-block validation) are wired to RecordBadBlock and each has an integration test verifying the bad-block surfaces in IBadBlockStore.GetAll().


Verdict

Severity Count
Critical 0
High 0
Medium 0
Low 2 (W3 style alias, W4 test comment — both optional)

Build is clean, all previous findings are resolved. Ready to merge.

@LukaszRozmej LukaszRozmej merged commit 4efa69f into NethermindEth:master Jun 2, 2026
913 of 917 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Metrics and debug_getBadBlocks for blocks rejected in other stages of validation

3 participants