From fe6f9b094eeeccc97158d1652dfa5e03275d78ca Mon Sep 17 00:00:00 2001 From: stavrosvl7 Date: Thu, 9 Apr 2026 17:53:57 +0300 Subject: [PATCH 01/23] engine api changes as per https://github.com/ethereum/execution-apis/pull/770 https://github.com/ethereum/execution-apis/pull/760 --- .../EngineModuleTests.V1.cs | 164 ++++++++++++++++++ .../BlockTreeExtensions.cs | 4 +- .../Handlers/ForkchoiceUpdatedHandler.cs | 16 +- 3 files changed, 179 insertions(+), 5 deletions(-) diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs index 68384926b4b9..90596d03b3cc 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs @@ -563,6 +563,170 @@ public async Task forkchoiceUpdatedV1_should_update_safe_block_hash() } + [Test] + public async Task forkchoiceUpdatedV1_WhenHeadIsAncestorWithinDepthLimit_ReorgsToAncestor() + { + // FCU to an ancestor within 32 blocks must execute the reorg, not skip it. + // Builds a chain: genesis → b1 → b2 → b3 (head at H=3), then sends FCU(b1). + // b1 is 2 blocks behind head — within the 32-block limit — so head must move back to b1. + using MergeTestBlockchain chain = await CreateBlockchain(); + IEngineRpcModule rpc = chain.EngineRpcModule; + + IReadOnlyList branch = await ProduceBranchV1(rpc, chain, 3, CreateParentBlockRequestOnHead(chain.BlockTree), setHead: true); + Hash256 b1Hash = branch[0].BlockHash; + Hash256 b3Hash = branch[2].BlockHash; + + chain.BlockTree.HeadHash.Should().Be(b3Hash, "precondition: head is at H=3"); + + ForkchoiceStateV1 fcuToAncestor = new(b1Hash, Keccak.Zero, Keccak.Zero); + ResultWrapper result = await rpc.engine_forkchoiceUpdatedV1(fcuToAncestor); + + result.Data.PayloadStatus.Status.Should().Be(PayloadStatus.Valid); + chain.BlockTree.HeadHash.Should().Be(b1Hash, "head must reorg back to b1 — within 32-block ancestor depth limit"); + } + + [Test] + public async Task forkchoiceUpdatedV1_WhenHeadIsAncestorBeyondDepthLimit_SkipsUpdate() + { + // FCU to an ancestor MORE than 32 blocks behind head must be skipped (treated as already canonical). + // Builds a chain of 34 blocks, then sends FCU to block at H=1 (33 blocks behind head at H=34). + using MergeTestBlockchain chain = await CreateBlockchain(); + IEngineRpcModule rpc = chain.EngineRpcModule; + + IReadOnlyList branch = await ProduceBranchV1(rpc, chain, 34, CreateParentBlockRequestOnHead(chain.BlockTree), setHead: true); + Hash256 b1Hash = branch[0].BlockHash; + Hash256 b34Hash = branch[33].BlockHash; + + chain.BlockTree.HeadHash.Should().Be(b34Hash, "precondition: head is at H=34"); + + ForkchoiceStateV1 fcuToDeepAncestor = new(b1Hash, Keccak.Zero, Keccak.Zero); + ResultWrapper result = await rpc.engine_forkchoiceUpdatedV1(fcuToDeepAncestor); + + result.Data.PayloadStatus.Status.Should().Be(PayloadStatus.Valid); + chain.BlockTree.HeadHash.Should().Be(b34Hash, "head must stay at H=34 — ancestor is beyond the 32-block depth limit"); + } + + [Test] + public async Task forkchoiceUpdatedV1_WhenHeadIsAncestorAtExactDepthLimit_ReorgsToAncestor() + { + // FCU to an ancestor exactly 32 blocks behind head must execute the reorg (boundary condition). + // Builds 33 blocks. b1 is exactly 32 blocks behind H=33 — must reorg. + using MergeTestBlockchain chain = await CreateBlockchain(); + IEngineRpcModule rpc = chain.EngineRpcModule; + + IReadOnlyList branch = await ProduceBranchV1(rpc, chain, 33, CreateParentBlockRequestOnHead(chain.BlockTree), setHead: true); + Hash256 b1Hash = branch[0].BlockHash; + Hash256 b33Hash = branch[32].BlockHash; + + chain.BlockTree.HeadHash.Should().Be(b33Hash, "precondition: head is at H=33"); + + ForkchoiceStateV1 fcuToAncestorAtLimit = new(b1Hash, Keccak.Zero, Keccak.Zero); + ResultWrapper result = await rpc.engine_forkchoiceUpdatedV1(fcuToAncestorAtLimit); + + result.Data.PayloadStatus.Status.Should().Be(PayloadStatus.Valid); + chain.BlockTree.HeadHash.Should().Be(b1Hash, "head must reorg to b1 — exactly at the 32-block depth limit"); + } + + [Test] + public async Task forkchoiceUpdatedV1_WhenHeadIsOnDifferentBranch_ReorgsRegardlessOfDepth() + { + // Spec: the 32-block depth limit only applies to ancestors of the canonical chain. + // A block on a different (non-canonical) branch must always trigger a reorg — no depth limit. + // Builds a canonical chain of 34 blocks, then a side branch of 1 block off genesis. + // The side block is 34 levels "behind" but is NOT a canonical ancestor — it's on a fork. + using MergeTestBlockchain chain = await CreateBlockchain(); + IEngineRpcModule rpc = chain.EngineRpcModule; + + // Build canonical chain: genesis → b1 → b2 → ... → b34 (head at H=34) + IReadOnlyList canonical = await ProduceBranchV1(rpc, chain, 34, CreateParentBlockRequestOnHead(chain.BlockTree), setHead: true); + Hash256 b34Hash = canonical[33].BlockHash; + chain.BlockTree.HeadHash.Should().Be(b34Hash, "precondition: canonical head is at H=34"); + + // Build a side block off genesis (H=1, different branch) + BlockHeader genesis = chain.BlockTree.Genesis!; + ExecutionPayload genesisAsParent = new ExecutionPayload + { + BlockNumber = genesis.Number, + BlockHash = genesis.Hash!, + StateRoot = genesis.StateRoot!, + ReceiptsRoot = genesis.ReceiptsRoot!, + GasLimit = genesis.GasLimit, + Timestamp = genesis.Timestamp, + BaseFeePerGas = genesis.BaseFeePerGas, + }; + ExecutionPayload sideBlock = CreateBlockRequest(chain, genesisAsParent, TestItem.AddressA); + await rpc.engine_newPayloadV1(sideBlock); + Hash256 sideHash = sideBlock.BlockHash; + chain.BlockTree.IsMainChain(chain.BlockTree.FindHeader(sideHash, BlockTreeLookupOptions.None)!).Should().BeFalse("precondition: side block is not on canonical chain"); + + // FCU to the side block — it's on a different branch, so it must reorg regardless of depth + ForkchoiceStateV1 fcuToSide = new(sideHash, Keccak.Zero, Keccak.Zero); + ResultWrapper result = await rpc.engine_forkchoiceUpdatedV1(fcuToSide); + + result.Data.PayloadStatus.Status.Should().Be(PayloadStatus.Valid); + chain.BlockTree.HeadHash.Should().Be(sideHash, "different-branch FCU must always reorg — depth limit does not apply"); + } + + [Test] + public async Task forkchoiceUpdatedV1_WhenZeroFinalizedAndSafeHash_ReturnsValidWithoutError() + { + // Spec: zero safeBlockHash and finalizedBlockHash mean "unknown" — must not return -38002. + // Models CL checkpoint-syncing from a non-finalized state where safe/finalized are unknown. + using MergeTestBlockchain chain = await CreateBlockchain(); + IEngineRpcModule rpc = chain.EngineRpcModule; + + IReadOnlyList branch = await ProduceBranchV1(rpc, chain, 3, CreateParentBlockRequestOnHead(chain.BlockTree), setHead: true); + Hash256 headHash = branch[2].BlockHash; + + ForkchoiceStateV1 fcuWithUnknownFinality = new(headHash, Keccak.Zero, Keccak.Zero); + ResultWrapper result = await rpc.engine_forkchoiceUpdatedV1(fcuWithUnknownFinality); + + result.ErrorCode.Should().Be(0, "zero safe/finalized hashes must not produce an error"); + result.Data.PayloadStatus.Status.Should().Be(PayloadStatus.Valid); + chain.BlockTree.HeadHash.Should().Be(headHash); + } + + [Test] + public async Task forkchoiceUpdatedV1_WhenZeroFinalizedHash_PreservesKnownFinalizedHash() + { + // Spec PR #760: when finalizedBlockHash is zero, client MUST use the latest known finalized hash — not overwrite it with zero. + using MergeTestBlockchain chain = await CreateBlockchain(); + IEngineRpcModule rpc = chain.EngineRpcModule; + + IReadOnlyList branch = await ProduceBranchV1(rpc, chain, 2, CreateParentBlockRequestOnHead(chain.BlockTree), setHead: true); + Hash256 b1Hash = branch[0].BlockHash; + Hash256 b2Hash = branch[1].BlockHash; + + // First FCU: finalize b1 + ForkchoiceStateV1 fcuWithFinalized = new(b2Hash, b1Hash, b1Hash); + await rpc.engine_forkchoiceUpdatedV1(fcuWithFinalized); + chain.BlockTree.FinalizedHash.Should().Be(b1Hash, "precondition: b1 is finalized after first FCU"); + + // Second FCU: zero finalizedBlockHash — must preserve b1 as finalized + ForkchoiceStateV1 fcuWithZeroFinalized = new(b2Hash, Keccak.Zero, Keccak.Zero); + ResultWrapper result = await rpc.engine_forkchoiceUpdatedV1(fcuWithZeroFinalized); + + result.Data.PayloadStatus.Status.Should().Be(PayloadStatus.Valid); + chain.BlockTree.FinalizedHash.Should().Be(b1Hash, "zero finalizedBlockHash must preserve the previously known finalized hash"); + } + + [Test] + public async Task forkchoiceUpdatedV1_WhenNonZeroUnknownFinalizedHash_ReturnsInvalidForkchoiceState() + { + // Spec: -38002 must only fire for non-zero hashes that are unknown, not for zero hashes. + using MergeTestBlockchain chain = await CreateBlockchain(); + IEngineRpcModule rpc = chain.EngineRpcModule; + + IReadOnlyList branch = await ProduceBranchV1(rpc, chain, 1, CreateParentBlockRequestOnHead(chain.BlockTree), setHead: true); + Hash256 headHash = branch[0].BlockHash; + + ForkchoiceStateV1 fcuWithUnknownFinalized = new(headHash, TestItem.KeccakA, Keccak.Zero); + ResultWrapper result = await rpc.engine_forkchoiceUpdatedV1(fcuWithUnknownFinalized); + + result.ErrorCode.Should().Be(MergeErrorCodes.InvalidForkchoiceState, + "non-zero unknown finalizedBlockHash must return -38002"); + } + [Test] public async Task forkchoiceUpdatedV1_should_work_with_zero_keccak_as_safe_block() { diff --git a/src/Nethermind/Nethermind.Merge.Plugin/BlockTreeExtensions.cs b/src/Nethermind/Nethermind.Merge.Plugin/BlockTreeExtensions.cs index 5b66b63a7548..4941f8da8525 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/BlockTreeExtensions.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/BlockTreeExtensions.cs @@ -8,9 +8,11 @@ namespace Nethermind.Merge.Plugin; public static class BlockTreeExtensions { + public const int AncestorReorgDepthLimit = 32; + public static bool IsOnMainChainBehindOrEqualHead(this IBlockTree blockTree, Block block) => block.Number <= (blockTree.Head?.Number ?? 0) && blockTree.IsMainChain(block.Header); public static bool IsOnMainChainBehindHead(this IBlockTree blockTree, Block block) => - block.Number < (blockTree.Head?.Number ?? 0) && blockTree.IsMainChain(block.Header); + (blockTree.Head?.Number ?? 0) - block.Number > AncestorReorgDepthLimit && blockTree.IsMainChain(block.Header); } diff --git a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/ForkchoiceUpdatedHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/ForkchoiceUpdatedHandler.cs index c2a7dbe302ce..94d0580fdffa 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/ForkchoiceUpdatedHandler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/ForkchoiceUpdatedHandler.cs @@ -167,9 +167,10 @@ protected virtual bool IsOnMainChainBehindHead(Block newHeadBlock, ForkchoiceSta if (!blockInfo.WasProcessed) { - if (!IsOnMainChainBehindHead(newHeadBlock, forkchoiceState, out ResultWrapper? errorResult)) + if (_blockTree.IsOnMainChainBehindOrEqualHead(newHeadBlock)) { - return errorResult; + if (_logger.IsInfo) _logger.Info($"Valid. ForkChoiceUpdated ignored - already in canonical chain."); + return ForkchoiceUpdatedV1Result.Valid(null, forkchoiceState.HeadBlockHash); } BlockHeader? blockParent = _blockTree.FindHeader(newHeadBlock.ParentHash!, blockNumber: newHeadBlock.Number - 1); @@ -284,7 +285,9 @@ protected virtual bool IsOnMainChainBehindHead(Block newHeadBlock, ForkchoiceSta if (_logger.IsInfo) _logger.Info($"Synced Chain Head to {newHeadBlock.ToString(Block.Format.Short)}"); } - _blockTree.ForkChoiceUpdated(forkchoiceState.FinalizedBlockHash, forkchoiceState.SafeBlockHash); + _blockTree.ForkChoiceUpdated( + ResolveZeroHash(forkchoiceState.FinalizedBlockHash, _blockTree.FinalizedHash), + ResolveZeroHash(forkchoiceState.SafeBlockHash, _blockTree.SafeHash)); return null; } @@ -332,7 +335,9 @@ private ResultWrapper StartBuildingPayload(Block newH payloadId = _payloadPreparationService.StartPreparingPayload(newHeadBlock.Header, payloadAttributes); } - _blockTree.ForkChoiceUpdated(forkchoiceState.FinalizedBlockHash, forkchoiceState.SafeBlockHash); + _blockTree.ForkChoiceUpdated( + ResolveZeroHash(forkchoiceState.FinalizedBlockHash, _blockTree.FinalizedHash), + ResolveZeroHash(forkchoiceState.SafeBlockHash, _blockTree.SafeHash)); return ForkchoiceUpdatedV1Result.Valid(isPayloadSimulated ? null : payloadId, forkchoiceState.HeadBlockHash); } @@ -362,6 +367,9 @@ private void StartNewBeaconHeaderSync(ForkchoiceStateV1 forkchoiceState, BlockHe private bool IsInconsistent(Hash256 blockHash) => blockHash != Keccak.Zero && !_blockTree.IsMainChain(blockHash); + private static Hash256 ResolveZeroHash(Hash256 hash, Hash256? knownHash) => + hash == Keccak.Zero ? knownHash ?? Keccak.Zero : hash; + private Block? GetBlock(Hash256 headBlockHash) { Block? block = _blockTree.FindBlock(headBlockHash, BlockTreeLookupOptions.DoNotCreateLevelIfMissing); From 4ba069c9c65ce7e7b4922d63c4e5341c17a80f71 Mon Sep 17 00:00:00 2001 From: stavrosvl7 Date: Thu, 9 Apr 2026 18:01:59 +0300 Subject: [PATCH 02/23] fix comment --- .../Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs index 90596d03b3cc..7367b8f4f651 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs @@ -588,8 +588,8 @@ public async Task forkchoiceUpdatedV1_WhenHeadIsAncestorWithinDepthLimit_ReorgsT [Test] public async Task forkchoiceUpdatedV1_WhenHeadIsAncestorBeyondDepthLimit_SkipsUpdate() { - // FCU to an ancestor MORE than 32 blocks behind head must be skipped (treated as already canonical). - // Builds a chain of 34 blocks, then sends FCU to block at H=1 (33 blocks behind head at H=34). + // Spec: client MAY skip the update when headBlockHash is a canonical ancestor more than 32 blocks behind head. + // Nethermind skips in this case. Builds a chain of 34 blocks, then sends FCU to H=1 (33 blocks behind H=34). using MergeTestBlockchain chain = await CreateBlockchain(); IEngineRpcModule rpc = chain.EngineRpcModule; @@ -603,7 +603,7 @@ public async Task forkchoiceUpdatedV1_WhenHeadIsAncestorBeyondDepthLimit_SkipsUp ResultWrapper result = await rpc.engine_forkchoiceUpdatedV1(fcuToDeepAncestor); result.Data.PayloadStatus.Status.Should().Be(PayloadStatus.Valid); - chain.BlockTree.HeadHash.Should().Be(b34Hash, "head must stay at H=34 — ancestor is beyond the 32-block depth limit"); + chain.BlockTree.HeadHash.Should().Be(b34Hash, "Nethermind skips the update — ancestor is beyond the 32-block depth limit, skip is permitted by spec"); } [Test] From a77e1827d4fb7d5e0a3a8739c8747a9a43279b7e Mon Sep 17 00:00:00 2001 From: stavrosvl7 Date: Thu, 9 Apr 2026 18:04:58 +0300 Subject: [PATCH 03/23] improve test --- .../Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs index 7367b8f4f651..eb40bc08ffad 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs @@ -603,6 +603,7 @@ public async Task forkchoiceUpdatedV1_WhenHeadIsAncestorBeyondDepthLimit_SkipsUp ResultWrapper result = await rpc.engine_forkchoiceUpdatedV1(fcuToDeepAncestor); result.Data.PayloadStatus.Status.Should().Be(PayloadStatus.Valid); + result.Data.PayloadStatus.LatestValidHash.Should().Be(b1Hash, "spec mandates latestValidHash == forkchoiceState.headBlockHash when skipping"); chain.BlockTree.HeadHash.Should().Be(b34Hash, "Nethermind skips the update — ancestor is beyond the 32-block depth limit, skip is permitted by spec"); } From 1e27be19466204dc0bfbb515edde6dbd90053d52 Mon Sep 17 00:00:00 2001 From: stavrosvl7 Date: Thu, 9 Apr 2026 18:12:04 +0300 Subject: [PATCH 04/23] improve tests matching the specs --- .../Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs index eb40bc08ffad..2454718c46c4 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs @@ -604,6 +604,7 @@ public async Task forkchoiceUpdatedV1_WhenHeadIsAncestorBeyondDepthLimit_SkipsUp result.Data.PayloadStatus.Status.Should().Be(PayloadStatus.Valid); result.Data.PayloadStatus.LatestValidHash.Should().Be(b1Hash, "spec mandates latestValidHash == forkchoiceState.headBlockHash when skipping"); + result.Data.PayloadId.Should().BeNull("spec mandates payloadId: null when skipping"); chain.BlockTree.HeadHash.Should().Be(b34Hash, "Nethermind skips the update — ancestor is beyond the 32-block depth limit, skip is permitted by spec"); } @@ -709,6 +710,7 @@ public async Task forkchoiceUpdatedV1_WhenZeroFinalizedHash_PreservesKnownFinali result.Data.PayloadStatus.Status.Should().Be(PayloadStatus.Valid); chain.BlockTree.FinalizedHash.Should().Be(b1Hash, "zero finalizedBlockHash must preserve the previously known finalized hash"); + chain.BlockTree.SafeHash.Should().Be(b1Hash, "zero safeBlockHash must preserve the previously known safe hash"); } [Test] From d06779353879b10f287b7405c319f23540125a52 Mon Sep 17 00:00:00 2001 From: stavrosvl7 Date: Thu, 9 Apr 2026 18:19:14 +0300 Subject: [PATCH 05/23] cleaner design --- .../Handlers/ForkchoiceUpdatedHandler.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/ForkchoiceUpdatedHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/ForkchoiceUpdatedHandler.cs index 94d0580fdffa..a91351c076ab 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/ForkchoiceUpdatedHandler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/ForkchoiceUpdatedHandler.cs @@ -94,7 +94,7 @@ public async Task> Handle(ForkchoiceSta protected virtual bool IsOnMainChainBehindHead(Block newHeadBlock, ForkchoiceStateV1 forkchoiceState, [NotNullWhen(false)] out ResultWrapper? errorResult) { - if (_blockTree.IsOnMainChainBehindHead(newHeadBlock)) + if (_blockTree.IsOnMainChainBehindOrEqualHead(newHeadBlock)) { if (_logger.IsInfo) _logger.Info($"Valid. ForkChoiceUpdated ignored - already in canonical chain."); errorResult = ForkchoiceUpdatedV1Result.Valid(null, forkchoiceState.HeadBlockHash); @@ -167,10 +167,9 @@ protected virtual bool IsOnMainChainBehindHead(Block newHeadBlock, ForkchoiceSta if (!blockInfo.WasProcessed) { - if (_blockTree.IsOnMainChainBehindOrEqualHead(newHeadBlock)) + if (!IsOnMainChainBehindHead(newHeadBlock, forkchoiceState, out ResultWrapper? errorResult)) { - if (_logger.IsInfo) _logger.Info($"Valid. ForkChoiceUpdated ignored - already in canonical chain."); - return ForkchoiceUpdatedV1Result.Valid(null, forkchoiceState.HeadBlockHash); + return errorResult; } BlockHeader? blockParent = _blockTree.FindHeader(newHeadBlock.ParentHash!, blockNumber: newHeadBlock.Number - 1); @@ -247,9 +246,10 @@ protected virtual bool IsOnMainChainBehindHead(Block newHeadBlock, ForkchoiceSta return ForkchoiceUpdatedV1Result.Error(setHeadErrorMsg, ErrorCodes.InvalidParams); } - if (!IsOnMainChainBehindHead(newHeadBlock, forkchoiceState, out ResultWrapper? result)) + if (_blockTree.IsOnMainChainBehindHead(newHeadBlock)) { - return result; + if (_logger.IsInfo) _logger.Info($"Valid. ForkChoiceUpdated ignored - already in canonical chain."); + return ForkchoiceUpdatedV1Result.Valid(null, forkchoiceState.HeadBlockHash); } bool newHeadTheSameAsCurrentHead = _blockTree.Head!.Hash == newHeadBlock.Hash; From 619259b75cb57403ab826708b1127f65412905a5 Mon Sep 17 00:00:00 2001 From: stavrosvl7 Date: Thu, 9 Apr 2026 18:32:59 +0300 Subject: [PATCH 06/23] taiko fix --- .../Handlers/ForkchoiceUpdatedHandler.cs | 12 ++--- .../TaikoEngineApiTests.cs | 44 +++++++++++++++++++ 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/ForkchoiceUpdatedHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/ForkchoiceUpdatedHandler.cs index a91351c076ab..94d0580fdffa 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/ForkchoiceUpdatedHandler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/ForkchoiceUpdatedHandler.cs @@ -94,7 +94,7 @@ public async Task> Handle(ForkchoiceSta protected virtual bool IsOnMainChainBehindHead(Block newHeadBlock, ForkchoiceStateV1 forkchoiceState, [NotNullWhen(false)] out ResultWrapper? errorResult) { - if (_blockTree.IsOnMainChainBehindOrEqualHead(newHeadBlock)) + if (_blockTree.IsOnMainChainBehindHead(newHeadBlock)) { if (_logger.IsInfo) _logger.Info($"Valid. ForkChoiceUpdated ignored - already in canonical chain."); errorResult = ForkchoiceUpdatedV1Result.Valid(null, forkchoiceState.HeadBlockHash); @@ -167,9 +167,10 @@ protected virtual bool IsOnMainChainBehindHead(Block newHeadBlock, ForkchoiceSta if (!blockInfo.WasProcessed) { - if (!IsOnMainChainBehindHead(newHeadBlock, forkchoiceState, out ResultWrapper? errorResult)) + if (_blockTree.IsOnMainChainBehindOrEqualHead(newHeadBlock)) { - return errorResult; + if (_logger.IsInfo) _logger.Info($"Valid. ForkChoiceUpdated ignored - already in canonical chain."); + return ForkchoiceUpdatedV1Result.Valid(null, forkchoiceState.HeadBlockHash); } BlockHeader? blockParent = _blockTree.FindHeader(newHeadBlock.ParentHash!, blockNumber: newHeadBlock.Number - 1); @@ -246,10 +247,9 @@ protected virtual bool IsOnMainChainBehindHead(Block newHeadBlock, ForkchoiceSta return ForkchoiceUpdatedV1Result.Error(setHeadErrorMsg, ErrorCodes.InvalidParams); } - if (_blockTree.IsOnMainChainBehindHead(newHeadBlock)) + if (!IsOnMainChainBehindHead(newHeadBlock, forkchoiceState, out ResultWrapper? result)) { - if (_logger.IsInfo) _logger.Info($"Valid. ForkChoiceUpdated ignored - already in canonical chain."); - return ForkchoiceUpdatedV1Result.Valid(null, forkchoiceState.HeadBlockHash); + return result; } bool newHeadTheSameAsCurrentHead = _blockTree.Head!.Hash == newHeadBlock.Hash; diff --git a/src/Nethermind/Nethermind.Taiko.Test/TaikoEngineApiTests.cs b/src/Nethermind/Nethermind.Taiko.Test/TaikoEngineApiTests.cs index 5dea7331076c..9455b273638c 100644 --- a/src/Nethermind/Nethermind.Taiko.Test/TaikoEngineApiTests.cs +++ b/src/Nethermind/Nethermind.Taiko.Test/TaikoEngineApiTests.cs @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: 2024 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only +using System.Collections.Generic; using NUnit.Framework; using Nethermind.Core; using Nethermind.Consensus.Producers; @@ -122,4 +123,47 @@ public async Task Test_ForkchoiceUpdatedHandler_Allows_Equal_Timestamps(ulong he Assert.That(result.Result.Error, Does.Contain("Invalid payload timestamp")); } } + + [Test] + public async Task IsOnMainChainBehindHead_Override_PreventsAncestorReorgSkip() + { + // Taiko overrides IsOnMainChainBehindHead to always proceed (return true). + // Without the override, FCU to a canonical ancestor >32 blocks behind head would be skipped + // and UpdateMainChain would NOT be called. With the override, it must be called. + IBlockTree blockTree = Substitute.For(); + + Block deepAncestor = Build.A.Block.WithNumber(1).TestObject; + Block headBlock = Build.A.Block.WithNumber(34).TestObject; + + blockTree.FindBlock(deepAncestor.Hash!, BlockTreeLookupOptions.DoNotCreateLevelIfMissing).Returns(deepAncestor); + blockTree.GetInfo(deepAncestor.Number, deepAncestor.Hash!).Returns( + (new BlockInfo(deepAncestor.Hash!, 0) { WasProcessed = true }, new ChainLevelInfo(true))); + blockTree.Head.Returns(headBlock); + blockTree.HeadHash.Returns(headBlock.Hash!); + blockTree.IsMainChain(Arg.Any()).Returns(true); + blockTree.IsMainChain(Arg.Any()).Returns(true); + + TaikoForkchoiceUpdatedHandler handler = new( + blockTree, + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + new MergeConfig(), + Substitute.For() + ); + + ResultWrapper result = await handler.Handle( + new ForkchoiceStateV1(deepAncestor.Hash!, Keccak.Zero, Keccak.Zero), null, 1); + + Assert.That(result.Data.PayloadStatus.Status, Is.EqualTo(PayloadStatus.Valid)); + blockTree.Received().UpdateMainChain(Arg.Any>(), true, true); + } } From 1b338f380ddff161e66a20a7fad584578d9214ed Mon Sep 17 00:00:00 2001 From: stavrosvl7 Date: Fri, 10 Apr 2026 12:08:13 +0300 Subject: [PATCH 07/23] claude review --- .../EngineModuleTests.V1.cs | 70 +++++++++++-------- .../BlockTreeExtensions.cs | 2 +- .../Handlers/ForkchoiceUpdatedHandler.cs | 6 +- .../TaikoEngineApiTests.cs | 4 +- .../Rpc/TaikoForkchoiceUpdatedHandler.cs | 2 +- 5 files changed, 47 insertions(+), 37 deletions(-) diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs index 2454718c46c4..e2d4106b4a25 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs @@ -563,26 +563,25 @@ public async Task forkchoiceUpdatedV1_should_update_safe_block_hash() } - [Test] - public async Task forkchoiceUpdatedV1_WhenHeadIsAncestorWithinDepthLimit_ReorgsToAncestor() + [TestCase(3, TestName = "2 blocks behind head — within limit")] + [TestCase(33, TestName = "exactly 32 blocks behind head — boundary")] + public async Task forkchoiceUpdatedV1_WhenHeadIsAncestorWithinOrAtDepthLimit_ReorgsToAncestor(int chainLength) { - // FCU to an ancestor within 32 blocks must execute the reorg, not skip it. - // Builds a chain: genesis → b1 → b2 → b3 (head at H=3), then sends FCU(b1). - // b1 is 2 blocks behind head — within the 32-block limit — so head must move back to b1. + // Spec: MUST support a reorg to a canonical ancestor no more than 32 blocks behind head. using MergeTestBlockchain chain = await CreateBlockchain(); IEngineRpcModule rpc = chain.EngineRpcModule; - IReadOnlyList branch = await ProduceBranchV1(rpc, chain, 3, CreateParentBlockRequestOnHead(chain.BlockTree), setHead: true); + IReadOnlyList branch = await ProduceBranchV1(rpc, chain, chainLength, CreateParentBlockRequestOnHead(chain.BlockTree), setHead: true); Hash256 b1Hash = branch[0].BlockHash; - Hash256 b3Hash = branch[2].BlockHash; + Hash256 headHash = branch[chainLength - 1].BlockHash; - chain.BlockTree.HeadHash.Should().Be(b3Hash, "precondition: head is at H=3"); + chain.BlockTree.HeadHash.Should().Be(headHash, $"precondition: head is at H={chainLength}"); ForkchoiceStateV1 fcuToAncestor = new(b1Hash, Keccak.Zero, Keccak.Zero); ResultWrapper result = await rpc.engine_forkchoiceUpdatedV1(fcuToAncestor); result.Data.PayloadStatus.Status.Should().Be(PayloadStatus.Valid); - chain.BlockTree.HeadHash.Should().Be(b1Hash, "head must reorg back to b1 — within 32-block ancestor depth limit"); + chain.BlockTree.HeadHash.Should().Be(b1Hash, $"head must reorg to b1 — depth is {chainLength - 1} blocks, within the 32-block limit"); } [Test] @@ -608,27 +607,6 @@ public async Task forkchoiceUpdatedV1_WhenHeadIsAncestorBeyondDepthLimit_SkipsUp chain.BlockTree.HeadHash.Should().Be(b34Hash, "Nethermind skips the update — ancestor is beyond the 32-block depth limit, skip is permitted by spec"); } - [Test] - public async Task forkchoiceUpdatedV1_WhenHeadIsAncestorAtExactDepthLimit_ReorgsToAncestor() - { - // FCU to an ancestor exactly 32 blocks behind head must execute the reorg (boundary condition). - // Builds 33 blocks. b1 is exactly 32 blocks behind H=33 — must reorg. - using MergeTestBlockchain chain = await CreateBlockchain(); - IEngineRpcModule rpc = chain.EngineRpcModule; - - IReadOnlyList branch = await ProduceBranchV1(rpc, chain, 33, CreateParentBlockRequestOnHead(chain.BlockTree), setHead: true); - Hash256 b1Hash = branch[0].BlockHash; - Hash256 b33Hash = branch[32].BlockHash; - - chain.BlockTree.HeadHash.Should().Be(b33Hash, "precondition: head is at H=33"); - - ForkchoiceStateV1 fcuToAncestorAtLimit = new(b1Hash, Keccak.Zero, Keccak.Zero); - ResultWrapper result = await rpc.engine_forkchoiceUpdatedV1(fcuToAncestorAtLimit); - - result.Data.PayloadStatus.Status.Should().Be(PayloadStatus.Valid); - chain.BlockTree.HeadHash.Should().Be(b1Hash, "head must reorg to b1 — exactly at the 32-block depth limit"); - } - [Test] public async Task forkchoiceUpdatedV1_WhenHeadIsOnDifferentBranch_ReorgsRegardlessOfDepth() { @@ -713,6 +691,38 @@ public async Task forkchoiceUpdatedV1_WhenZeroFinalizedHash_PreservesKnownFinali chain.BlockTree.SafeHash.Should().Be(b1Hash, "zero safeBlockHash must preserve the previously known safe hash"); } + [Test] + public async Task forkchoiceUpdatedV1_WhenZeroFinalizedHash_PreservesKnownFinalizedHash_WithPayloadAttributes() + { + // Spec PR #760: ResolveZeroHash is called in StartBuildingPayload too — verify preservation on that path. + using MergeTestBlockchain chain = await CreateBlockchain(); + IEngineRpcModule rpc = chain.EngineRpcModule; + + IReadOnlyList branch = await ProduceBranchV1(rpc, chain, 2, CreateParentBlockRequestOnHead(chain.BlockTree), setHead: true); + Hash256 b1Hash = branch[0].BlockHash; + Hash256 b2Hash = branch[1].BlockHash; + + // First FCU: finalize b1 + ForkchoiceStateV1 fcuWithFinalized = new(b2Hash, b1Hash, b1Hash); + await rpc.engine_forkchoiceUpdatedV1(fcuWithFinalized); + chain.BlockTree.FinalizedHash.Should().Be(b1Hash, "precondition: b1 is finalized after first FCU"); + + // Second FCU with payload attributes — exercises the StartBuildingPayload call site of ResolveZeroHash + PayloadAttributes payloadAttributes = new() + { + Timestamp = branch[1].Timestamp + 1, + PrevRandao = Keccak.Zero, + SuggestedFeeRecipient = Address.Zero + }; + ForkchoiceStateV1 fcuWithZeroFinalized = new(b2Hash, Keccak.Zero, Keccak.Zero); + ResultWrapper result = await rpc.engine_forkchoiceUpdatedV1(fcuWithZeroFinalized, payloadAttributes); + + result.Data.PayloadStatus.Status.Should().Be(PayloadStatus.Valid); + result.Data.PayloadId.Should().NotBeNull("payload build must be started when attributes are provided"); + chain.BlockTree.FinalizedHash.Should().Be(b1Hash, "zero finalizedBlockHash must preserve the previously known finalized hash via StartBuildingPayload"); + chain.BlockTree.SafeHash.Should().Be(b1Hash, "zero safeBlockHash must preserve the previously known safe hash via StartBuildingPayload"); + } + [Test] public async Task forkchoiceUpdatedV1_WhenNonZeroUnknownFinalizedHash_ReturnsInvalidForkchoiceState() { diff --git a/src/Nethermind/Nethermind.Merge.Plugin/BlockTreeExtensions.cs b/src/Nethermind/Nethermind.Merge.Plugin/BlockTreeExtensions.cs index 4941f8da8525..dc784b631b96 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/BlockTreeExtensions.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/BlockTreeExtensions.cs @@ -13,6 +13,6 @@ public static class BlockTreeExtensions public static bool IsOnMainChainBehindOrEqualHead(this IBlockTree blockTree, Block block) => block.Number <= (blockTree.Head?.Number ?? 0) && blockTree.IsMainChain(block.Header); - public static bool IsOnMainChainBehindHead(this IBlockTree blockTree, Block block) => + public static bool IsAncestorOnMainChainBeyondReorgDepthLimit(this IBlockTree blockTree, Block block) => (blockTree.Head?.Number ?? 0) - block.Number > AncestorReorgDepthLimit && blockTree.IsMainChain(block.Header); } diff --git a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/ForkchoiceUpdatedHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/ForkchoiceUpdatedHandler.cs index 94d0580fdffa..20c41319df2b 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/ForkchoiceUpdatedHandler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/ForkchoiceUpdatedHandler.cs @@ -91,10 +91,10 @@ public async Task> Handle(ForkchoiceSta ?? StartBuildingPayload(newHeadBlock!, forkchoiceState, payloadAttributes); } - protected virtual bool IsOnMainChainBehindHead(Block newHeadBlock, ForkchoiceStateV1 forkchoiceState, + protected virtual bool IsAncestorOnMainChainBeyondReorgDepthLimit(Block newHeadBlock, ForkchoiceStateV1 forkchoiceState, [NotNullWhen(false)] out ResultWrapper? errorResult) { - if (_blockTree.IsOnMainChainBehindHead(newHeadBlock)) + if (_blockTree.IsAncestorOnMainChainBeyondReorgDepthLimit(newHeadBlock)) { if (_logger.IsInfo) _logger.Info($"Valid. ForkChoiceUpdated ignored - already in canonical chain."); errorResult = ForkchoiceUpdatedV1Result.Valid(null, forkchoiceState.HeadBlockHash); @@ -247,7 +247,7 @@ protected virtual bool IsOnMainChainBehindHead(Block newHeadBlock, ForkchoiceSta return ForkchoiceUpdatedV1Result.Error(setHeadErrorMsg, ErrorCodes.InvalidParams); } - if (!IsOnMainChainBehindHead(newHeadBlock, forkchoiceState, out ResultWrapper? result)) + if (!IsAncestorOnMainChainBeyondReorgDepthLimit(newHeadBlock, forkchoiceState, out ResultWrapper? result)) { return result; } diff --git a/src/Nethermind/Nethermind.Taiko.Test/TaikoEngineApiTests.cs b/src/Nethermind/Nethermind.Taiko.Test/TaikoEngineApiTests.cs index 9455b273638c..d2caaeb860bf 100644 --- a/src/Nethermind/Nethermind.Taiko.Test/TaikoEngineApiTests.cs +++ b/src/Nethermind/Nethermind.Taiko.Test/TaikoEngineApiTests.cs @@ -125,9 +125,9 @@ public async Task Test_ForkchoiceUpdatedHandler_Allows_Equal_Timestamps(ulong he } [Test] - public async Task IsOnMainChainBehindHead_Override_PreventsAncestorReorgSkip() + public async Task IsAncestorOnMainChainBeyondReorgDepthLimit_Override_PreventsAncestorReorgSkip() { - // Taiko overrides IsOnMainChainBehindHead to always proceed (return true). + // Taiko overrides IsAncestorOnMainChainBeyondReorgDepthLimit to always proceed (return true). // Without the override, FCU to a canonical ancestor >32 blocks behind head would be skipped // and UpdateMainChain would NOT be called. With the override, it must be called. IBlockTree blockTree = Substitute.For(); diff --git a/src/Nethermind/Nethermind.Taiko/Rpc/TaikoForkchoiceUpdatedHandler.cs b/src/Nethermind/Nethermind.Taiko/Rpc/TaikoForkchoiceUpdatedHandler.cs index 5cc5ef6b4e95..2792b0127168 100644 --- a/src/Nethermind/Nethermind.Taiko/Rpc/TaikoForkchoiceUpdatedHandler.cs +++ b/src/Nethermind/Nethermind.Taiko/Rpc/TaikoForkchoiceUpdatedHandler.cs @@ -52,7 +52,7 @@ ILogManager logManager mergeConfig, logManager) { - protected override bool IsOnMainChainBehindHead(Block newHeadBlock, ForkchoiceStateV1 forkchoiceState, + protected override bool IsAncestorOnMainChainBeyondReorgDepthLimit(Block newHeadBlock, ForkchoiceStateV1 forkchoiceState, [NotNullWhen(false)] out ResultWrapper? errorResult) { errorResult = null; From 4d0f215ad0e184ac4e4e73a2411eae0d80b69100 Mon Sep 17 00:00:00 2001 From: stavrosvl7 Date: Fri, 10 Apr 2026 17:23:42 +0300 Subject: [PATCH 08/23] claude review --- .../EngineModuleTests.V1.cs | 16 ++++------------ .../Handlers/ForkchoiceUpdatedHandler.cs | 6 +++--- .../Nethermind.Taiko.Test/TaikoEngineApiTests.cs | 4 ++-- .../Rpc/TaikoForkchoiceUpdatedHandler.cs | 2 +- 4 files changed, 10 insertions(+), 18 deletions(-) diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs index e2d4106b4a25..85510ce855d0 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs @@ -617,23 +617,15 @@ public async Task forkchoiceUpdatedV1_WhenHeadIsOnDifferentBranch_ReorgsRegardle using MergeTestBlockchain chain = await CreateBlockchain(); IEngineRpcModule rpc = chain.EngineRpcModule; + // Capture genesis as parent before building canonical chain + ExecutionPayload genesisAsParent = CreateParentBlockRequestOnHead(chain.BlockTree); + // Build canonical chain: genesis → b1 → b2 → ... → b34 (head at H=34) - IReadOnlyList canonical = await ProduceBranchV1(rpc, chain, 34, CreateParentBlockRequestOnHead(chain.BlockTree), setHead: true); + IReadOnlyList canonical = await ProduceBranchV1(rpc, chain, 34, genesisAsParent, setHead: true); Hash256 b34Hash = canonical[33].BlockHash; chain.BlockTree.HeadHash.Should().Be(b34Hash, "precondition: canonical head is at H=34"); // Build a side block off genesis (H=1, different branch) - BlockHeader genesis = chain.BlockTree.Genesis!; - ExecutionPayload genesisAsParent = new ExecutionPayload - { - BlockNumber = genesis.Number, - BlockHash = genesis.Hash!, - StateRoot = genesis.StateRoot!, - ReceiptsRoot = genesis.ReceiptsRoot!, - GasLimit = genesis.GasLimit, - Timestamp = genesis.Timestamp, - BaseFeePerGas = genesis.BaseFeePerGas, - }; ExecutionPayload sideBlock = CreateBlockRequest(chain, genesisAsParent, TestItem.AddressA); await rpc.engine_newPayloadV1(sideBlock); Hash256 sideHash = sideBlock.BlockHash; diff --git a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/ForkchoiceUpdatedHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/ForkchoiceUpdatedHandler.cs index 20c41319df2b..bf49d49116d4 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/ForkchoiceUpdatedHandler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/ForkchoiceUpdatedHandler.cs @@ -91,7 +91,7 @@ public async Task> Handle(ForkchoiceSta ?? StartBuildingPayload(newHeadBlock!, forkchoiceState, payloadAttributes); } - protected virtual bool IsAncestorOnMainChainBeyondReorgDepthLimit(Block newHeadBlock, ForkchoiceStateV1 forkchoiceState, + protected virtual bool ShouldProceedWithReorg(Block newHeadBlock, ForkchoiceStateV1 forkchoiceState, [NotNullWhen(false)] out ResultWrapper? errorResult) { if (_blockTree.IsAncestorOnMainChainBeyondReorgDepthLimit(newHeadBlock)) @@ -247,7 +247,7 @@ protected virtual bool IsAncestorOnMainChainBeyondReorgDepthLimit(Block newHeadB return ForkchoiceUpdatedV1Result.Error(setHeadErrorMsg, ErrorCodes.InvalidParams); } - if (!IsAncestorOnMainChainBeyondReorgDepthLimit(newHeadBlock, forkchoiceState, out ResultWrapper? result)) + if (!ShouldProceedWithReorg(newHeadBlock, forkchoiceState, out ResultWrapper? result)) { return result; } @@ -281,7 +281,7 @@ protected virtual bool IsAncestorOnMainChainBeyondReorgDepthLimit(Block newHeadB if (shouldUpdateHead) { - _poSSwitcher.ForkchoiceUpdated(newHeadBlock.Header, finalizedBlockHash); + _poSSwitcher.ForkchoiceUpdated(newHeadBlock.Header, ResolveZeroHash(finalizedBlockHash, _blockTree.FinalizedHash)); if (_logger.IsInfo) _logger.Info($"Synced Chain Head to {newHeadBlock.ToString(Block.Format.Short)}"); } diff --git a/src/Nethermind/Nethermind.Taiko.Test/TaikoEngineApiTests.cs b/src/Nethermind/Nethermind.Taiko.Test/TaikoEngineApiTests.cs index d2caaeb860bf..01525253bae9 100644 --- a/src/Nethermind/Nethermind.Taiko.Test/TaikoEngineApiTests.cs +++ b/src/Nethermind/Nethermind.Taiko.Test/TaikoEngineApiTests.cs @@ -125,9 +125,9 @@ public async Task Test_ForkchoiceUpdatedHandler_Allows_Equal_Timestamps(ulong he } [Test] - public async Task IsAncestorOnMainChainBeyondReorgDepthLimit_Override_PreventsAncestorReorgSkip() + public async Task ShouldProceedWithReorg_Override_PreventsAncestorReorgSkip() { - // Taiko overrides IsAncestorOnMainChainBeyondReorgDepthLimit to always proceed (return true). + // Taiko overrides ShouldProceedWithReorg to always proceed (return true). // Without the override, FCU to a canonical ancestor >32 blocks behind head would be skipped // and UpdateMainChain would NOT be called. With the override, it must be called. IBlockTree blockTree = Substitute.For(); diff --git a/src/Nethermind/Nethermind.Taiko/Rpc/TaikoForkchoiceUpdatedHandler.cs b/src/Nethermind/Nethermind.Taiko/Rpc/TaikoForkchoiceUpdatedHandler.cs index 2792b0127168..1bde3407fd7d 100644 --- a/src/Nethermind/Nethermind.Taiko/Rpc/TaikoForkchoiceUpdatedHandler.cs +++ b/src/Nethermind/Nethermind.Taiko/Rpc/TaikoForkchoiceUpdatedHandler.cs @@ -52,7 +52,7 @@ ILogManager logManager mergeConfig, logManager) { - protected override bool IsAncestorOnMainChainBeyondReorgDepthLimit(Block newHeadBlock, ForkchoiceStateV1 forkchoiceState, + protected override bool ShouldProceedWithReorg(Block newHeadBlock, ForkchoiceStateV1 forkchoiceState, [NotNullWhen(false)] out ResultWrapper? errorResult) { errorResult = null; From d1d0370429045dc3819dbbe4c09785227526f21e Mon Sep 17 00:00:00 2001 From: stavrosvl7 Date: Fri, 10 Apr 2026 17:30:54 +0300 Subject: [PATCH 09/23] more fixes --- .../Handlers/ForkchoiceUpdatedHandler.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/ForkchoiceUpdatedHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/ForkchoiceUpdatedHandler.cs index bf49d49116d4..579bdb592bfe 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/ForkchoiceUpdatedHandler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/ForkchoiceUpdatedHandler.cs @@ -279,15 +279,16 @@ protected virtual bool ShouldProceedWithReorg(Block newHeadBlock, ForkchoiceStat _manualBlockFinalizationManager.MarkFinalized(newHeadBlock.Header, finalizedHeader!); } + Hash256 resolvedFinalizedHash = ResolveZeroHash(finalizedBlockHash, _blockTree.FinalizedHash); + Hash256 resolvedSafeHash = ResolveZeroHash(safeBlockHash, _blockTree.SafeHash); + if (shouldUpdateHead) { - _poSSwitcher.ForkchoiceUpdated(newHeadBlock.Header, ResolveZeroHash(finalizedBlockHash, _blockTree.FinalizedHash)); + _poSSwitcher.ForkchoiceUpdated(newHeadBlock.Header, resolvedFinalizedHash); if (_logger.IsInfo) _logger.Info($"Synced Chain Head to {newHeadBlock.ToString(Block.Format.Short)}"); } - _blockTree.ForkChoiceUpdated( - ResolveZeroHash(forkchoiceState.FinalizedBlockHash, _blockTree.FinalizedHash), - ResolveZeroHash(forkchoiceState.SafeBlockHash, _blockTree.SafeHash)); + _blockTree.ForkChoiceUpdated(resolvedFinalizedHash, resolvedSafeHash); return null; } From a8c884a092cc7e7fcd4f38a7bd9d30e0e5836b06 Mon Sep 17 00:00:00 2001 From: stavrosvl7 Date: Mon, 20 Apr 2026 14:54:40 +0300 Subject: [PATCH 10/23] conflicts fix --- src/Nethermind/Nethermind.Merge.Plugin/BlockTreeExtensions.cs | 2 +- .../Handlers/ForkchoiceUpdatedHandler.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Nethermind/Nethermind.Merge.Plugin/BlockTreeExtensions.cs b/src/Nethermind/Nethermind.Merge.Plugin/BlockTreeExtensions.cs index d4d7c876c790..3adc1d8fe096 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/BlockTreeExtensions.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/BlockTreeExtensions.cs @@ -13,6 +13,6 @@ public static class BlockTreeExtensions public static bool IsOnMainChainBehindOrEqualHead(this IBlockTree blockTree, BlockHeader header) => header.Number <= (blockTree.Head?.Number ?? 0) && blockTree.IsMainChain(header); - public static bool IsOnMainChainBehindHead(this IBlockTree blockTree, BlockHeader header) => + public static bool IsAncestorOnMainChainBeyondReorgDepthLimit(this IBlockTree blockTree, BlockHeader header) => (blockTree.Head?.Number ?? 0) - header.Number > AncestorReorgDepthLimit && blockTree.IsMainChain(header); } diff --git a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/ForkchoiceUpdatedHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/ForkchoiceUpdatedHandler.cs index 0f4a9ecc76af..103f0597c14f 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/ForkchoiceUpdatedHandler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/ForkchoiceUpdatedHandler.cs @@ -67,7 +67,7 @@ public async Task> Handle(ForkchoiceSta protected virtual bool ShouldProceedWithReorg(BlockHeader newHeadHeader, ForkchoiceStateV1 forkchoiceState, [NotNullWhen(false)] out ResultWrapper? errorResult) { - if (_blockTree.IsOnMainChainBehindHead(newHeadHeader)) + if (_blockTree.IsAncestorOnMainChainBeyondReorgDepthLimit(newHeadHeader)) { if (_logger.IsInfo) _logger.Info($"Valid. ForkChoiceUpdated ignored - already in canonical chain."); errorResult = ForkchoiceUpdatedV1Result.Valid(null, forkchoiceState.HeadBlockHash); From b30ef5c436dd6a2e6c7f352245205a41e0b26cd7 Mon Sep 17 00:00:00 2001 From: stavrosvl7 Date: Mon, 20 Apr 2026 15:34:35 +0300 Subject: [PATCH 11/23] fix tests --- .../Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs index 298052c92231..16d8444dbbe7 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs @@ -666,11 +666,11 @@ public async Task forkchoiceUpdatedV1_WhenZeroFinalizedHash_PreservesKnownFinali using MergeTestBlockchain chain = await CreateBlockchain(); IEngineRpcModule rpc = chain.EngineRpcModule; - IReadOnlyList branch = await ProduceBranchV1(rpc, chain, 2, CreateParentBlockRequestOnHead(chain.BlockTree), setHead: true); + IReadOnlyList branch = await ProduceBranchV1(rpc, chain, 2, CreateParentBlockRequestOnHead(chain.BlockTree), setHead: false); Hash256 b1Hash = branch[0].BlockHash; Hash256 b2Hash = branch[1].BlockHash; - // First FCU: finalize b1 + // First FCU: set head to b2 and finalize b1 ForkchoiceStateV1 fcuWithFinalized = new(b2Hash, b1Hash, b1Hash); await rpc.engine_forkchoiceUpdatedV1(fcuWithFinalized); chain.BlockTree.FinalizedHash.Should().Be(b1Hash, "precondition: b1 is finalized after first FCU"); @@ -691,11 +691,11 @@ public async Task forkchoiceUpdatedV1_WhenZeroFinalizedHash_PreservesKnownFinali using MergeTestBlockchain chain = await CreateBlockchain(); IEngineRpcModule rpc = chain.EngineRpcModule; - IReadOnlyList branch = await ProduceBranchV1(rpc, chain, 2, CreateParentBlockRequestOnHead(chain.BlockTree), setHead: true); + IReadOnlyList branch = await ProduceBranchV1(rpc, chain, 2, CreateParentBlockRequestOnHead(chain.BlockTree), setHead: false); Hash256 b1Hash = branch[0].BlockHash; Hash256 b2Hash = branch[1].BlockHash; - // First FCU: finalize b1 + // First FCU: set head to b2 and finalize b1 ForkchoiceStateV1 fcuWithFinalized = new(b2Hash, b1Hash, b1Hash); await rpc.engine_forkchoiceUpdatedV1(fcuWithFinalized); chain.BlockTree.FinalizedHash.Should().Be(b1Hash, "precondition: b1 is finalized after first FCU"); From 3d2c973a68c5ce443a468790766905dfcbf0d7f0 Mon Sep 17 00:00:00 2001 From: stavrosvl7 Date: Mon, 20 Apr 2026 16:09:17 +0300 Subject: [PATCH 12/23] fix taiko tests --- src/Nethermind/Nethermind.Taiko.Test/TaikoEngineApiTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Nethermind/Nethermind.Taiko.Test/TaikoEngineApiTests.cs b/src/Nethermind/Nethermind.Taiko.Test/TaikoEngineApiTests.cs index bfa4fc441c29..85b6ee6c08a6 100644 --- a/src/Nethermind/Nethermind.Taiko.Test/TaikoEngineApiTests.cs +++ b/src/Nethermind/Nethermind.Taiko.Test/TaikoEngineApiTests.cs @@ -8,6 +8,7 @@ using Nethermind.Merge.Plugin.Handlers; using System.Threading.Tasks; using Nethermind.Blockchain; +using Nethermind.Blockchain.Find; using Nethermind.Consensus.Processing; using Nethermind.Consensus; using Nethermind.Core.Crypto; @@ -142,6 +143,7 @@ public async Task ShouldProceedWithReorg_Override_PreventsAncestorReorgSkip() blockTree.HeadHash.Returns(headBlock.Hash!); blockTree.IsMainChain(Arg.Any()).Returns(true); blockTree.IsMainChain(Arg.Any()).Returns(true); + blockTree.FindHeader(deepAncestor.Hash!, BlockTreeLookupOptions.DoNotCreateLevelIfMissing).Returns(deepAncestor.Header); TaikoForkchoiceUpdatedHandler handler = new( blockTree, From bca0c634462fadb7a479dcb2e63786b70845ede1 Mon Sep 17 00:00:00 2001 From: stavrosvl7 Date: Mon, 20 Apr 2026 16:14:10 +0300 Subject: [PATCH 13/23] remove unused import --- src/Nethermind/Nethermind.Taiko.Test/TaikoEngineApiTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Nethermind/Nethermind.Taiko.Test/TaikoEngineApiTests.cs b/src/Nethermind/Nethermind.Taiko.Test/TaikoEngineApiTests.cs index 85b6ee6c08a6..16b7bd2eba0c 100644 --- a/src/Nethermind/Nethermind.Taiko.Test/TaikoEngineApiTests.cs +++ b/src/Nethermind/Nethermind.Taiko.Test/TaikoEngineApiTests.cs @@ -8,7 +8,6 @@ using Nethermind.Merge.Plugin.Handlers; using System.Threading.Tasks; using Nethermind.Blockchain; -using Nethermind.Blockchain.Find; using Nethermind.Consensus.Processing; using Nethermind.Consensus; using Nethermind.Core.Crypto; From 9c03d12606e21bbc6306d70419437a7c55d535e1 Mon Sep 17 00:00:00 2001 From: stavrosvl7 Date: Sat, 25 Apr 2026 22:32:58 +0300 Subject: [PATCH 14/23] update per 786 --- .../BlockTreeExtensions.cs | 5 ---- .../Handlers/ForkchoiceUpdatedHandler.cs | 29 +++++++++++++++++-- .../MergeErrorCodes.cs | 5 ++++ .../TaikoEngineApiTests.cs | 19 ++++++++---- .../Rpc/TaikoForkchoiceUpdatedHandler.cs | 3 ++ 5 files changed, 48 insertions(+), 13 deletions(-) diff --git a/src/Nethermind/Nethermind.Merge.Plugin/BlockTreeExtensions.cs b/src/Nethermind/Nethermind.Merge.Plugin/BlockTreeExtensions.cs index 3adc1d8fe096..4d0ce13db71b 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/BlockTreeExtensions.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/BlockTreeExtensions.cs @@ -8,11 +8,6 @@ namespace Nethermind.Merge.Plugin; public static class BlockTreeExtensions { - public const int AncestorReorgDepthLimit = 32; - public static bool IsOnMainChainBehindOrEqualHead(this IBlockTree blockTree, BlockHeader header) => header.Number <= (blockTree.Head?.Number ?? 0) && blockTree.IsMainChain(header); - - public static bool IsAncestorOnMainChainBeyondReorgDepthLimit(this IBlockTree blockTree, BlockHeader header) => - (blockTree.Head?.Number ?? 0) - header.Number > AncestorReorgDepthLimit && blockTree.IsMainChain(header); } diff --git a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/ForkchoiceUpdatedHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/ForkchoiceUpdatedHandler.cs index 36ce057f2d31..b842f464caad 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/ForkchoiceUpdatedHandler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/ForkchoiceUpdatedHandler.cs @@ -16,6 +16,7 @@ using Nethermind.Core.Specs; using Nethermind.Core.Threading; using Nethermind.Crypto; +using Nethermind.Db; using Nethermind.JsonRpc; using Nethermind.Logging; using Nethermind.Merge.Plugin.BlockProduction; @@ -48,6 +49,7 @@ public class ForkchoiceUpdatedHandler( ISpecProvider specProvider, ISyncPeerPool syncPeerPool, IMergeConfig mergeConfig, + IPruningConfig pruningConfig, ILogManager logManager) : IForkchoiceUpdatedHandler { protected readonly IBlockTree _blockTree = blockTree ?? throw new ArgumentNullException(nameof(blockTree)); @@ -55,6 +57,7 @@ public class ForkchoiceUpdatedHandler( private readonly IPoSSwitcher _poSSwitcher = poSSwitcher ?? throw new ArgumentNullException(nameof(poSSwitcher)); private readonly ILogger _logger = logManager.GetClassLogger(); private readonly bool _simulateBlockProduction = mergeConfig.SimulateBlockProduction; + private readonly int _maxReorgDepth = pruningConfig.PruningBoundary; public async Task> Handle(ForkchoiceStateV1 forkchoiceState, PayloadAttributes? payloadAttributes, int version) { @@ -64,13 +67,33 @@ public async Task> Handle(ForkchoiceSta ?? StartBuildingPayload(newHeadHeader!, forkchoiceState, payloadAttributes); } + // Spec point 2: MAY skip if headBlockHash is a VALID ancestor of the latest known finalized block. + // Spec point 6: MUST return -38006 if reorg depth exceeds IPruningConfig.PruningBoundary. + // Taiko overrides this to always proceed because its finality follows L1 and may regress on L1 reorgs. protected virtual bool ShouldProceedWithReorg(BlockHeader newHeadHeader, ForkchoiceStateV1 forkchoiceState, [NotNullWhen(false)] out ResultWrapper? errorResult) { - if (_blockTree.IsAncestorOnMainChainBeyondReorgDepthLimit(newHeadHeader)) + Hash256? knownFinalizedHash = _blockTree.FinalizedHash; + if (knownFinalizedHash is not null && knownFinalizedHash != Keccak.Zero) { - if (_logger.IsInfo) _logger.Info($"Valid. ForkChoiceUpdated ignored - already in canonical chain."); - errorResult = ForkchoiceUpdatedV1Result.Valid(null, forkchoiceState.HeadBlockHash); + BlockHeader? knownFinalizedHeader = _blockTree.FindHeader(knownFinalizedHash, BlockTreeLookupOptions.DoNotCreateLevelIfMissing); + if (knownFinalizedHeader is null) + { + if (_logger.IsWarn) _logger.Warn($"Known finalized hash {knownFinalizedHash} has no header — cannot check spec point 2 ancestry. Falling back to depth limit."); + } + else if (newHeadHeader.Number < knownFinalizedHeader.Number && _blockTree.IsMainChain(newHeadHeader)) + { + if (_logger.IsInfo) _logger.Info($"Valid. ForkChoiceUpdated skipped - head is a valid ancestor of the latest known finalized block."); + errorResult = ForkchoiceUpdatedV1Result.Valid(null, forkchoiceState.HeadBlockHash); + return false; + } + } + + long reorgDepth = (_blockTree.Head?.Number ?? 0) - newHeadHeader.Number; + if (reorgDepth > _maxReorgDepth) + { + if (_logger.IsWarn) _logger.Warn($"Too deep reorg. Reorg depth: {reorgDepth}, limit: {_maxReorgDepth}. Request: {forkchoiceState}."); + errorResult = ResultWrapper.Fail("Too deep reorg", MergeErrorCodes.TooDeepReorg); return false; } diff --git a/src/Nethermind/Nethermind.Merge.Plugin/MergeErrorCodes.cs b/src/Nethermind/Nethermind.Merge.Plugin/MergeErrorCodes.cs index f47dfa4df2ae..4acee4082cda 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/MergeErrorCodes.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/MergeErrorCodes.cs @@ -34,4 +34,9 @@ public static class MergeErrorCodes /// Payload attributes are invalid or inconsistent. /// public const int UnsupportedFork = -38005; + + /// + /// Reorg depth exceeds the client-specific limitation. + /// + public const int TooDeepReorg = -38006; } diff --git a/src/Nethermind/Nethermind.Taiko.Test/TaikoEngineApiTests.cs b/src/Nethermind/Nethermind.Taiko.Test/TaikoEngineApiTests.cs index 16b7bd2eba0c..cb9b6b9c69b9 100644 --- a/src/Nethermind/Nethermind.Taiko.Test/TaikoEngineApiTests.cs +++ b/src/Nethermind/Nethermind.Taiko.Test/TaikoEngineApiTests.cs @@ -19,6 +19,7 @@ using Nethermind.Synchronization.Peers; using NSubstitute; using Nethermind.Core.Test.Builders; +using Nethermind.Db; using Nethermind.Merge.Plugin.Data; using Nethermind.JsonRpc; using Nethermind.Merge.Plugin; @@ -52,6 +53,7 @@ public async Task Test_ForkchoiceUpdatedHandler_Allows_UnknownFinalizedSafeBlock Substitute.For(), Substitute.For(), new MergeConfig(), + Substitute.For(), Substitute.For() ); @@ -101,6 +103,7 @@ public async Task Test_ForkchoiceUpdatedHandler_Allows_Equal_Timestamps(ulong he Substitute.For(), Substitute.For(), new MergeConfig(), + Substitute.For(), Substitute.For() ); @@ -125,15 +128,17 @@ public async Task Test_ForkchoiceUpdatedHandler_Allows_Equal_Timestamps(ulong he } [Test] - public async Task ShouldProceedWithReorg_Override_PreventsAncestorReorgSkip() + public async Task ShouldProceedWithReorg_Override_BypassesTooDeepReorgError() { // Taiko overrides ShouldProceedWithReorg to always proceed (return true). - // Without the override, FCU to a canonical ancestor >32 blocks behind head would be skipped - // and UpdateMainChain would NOT be called. With the override, it must be called. + // Without the override, FCU to a block whose reorg depth exceeds IPruningConfig.PruningBoundary + // would return -38006 Too deep reorg. With the override, it must proceed regardless. IBlockTree blockTree = Substitute.For(); - Block deepAncestor = Build.A.Block.WithNumber(1).TestObject; - Block headBlock = Build.A.Block.WithNumber(34).TestObject; + const int pruningBoundary = 64; + Block headBlock = Build.A.Block.WithNumber(100).TestObject; + // reorg depth = 100 - 33 = 67, which exceeds pruningBoundary of 64 + Block deepAncestor = Build.A.Block.WithNumber(33).TestObject; blockTree.FindBlock(deepAncestor.Hash!, BlockTreeLookupOptions.DoNotCreateLevelIfMissing).Returns(deepAncestor); blockTree.GetInfo(deepAncestor.Number, deepAncestor.Hash!).Returns( @@ -144,6 +149,9 @@ public async Task ShouldProceedWithReorg_Override_PreventsAncestorReorgSkip() blockTree.IsMainChain(Arg.Any()).Returns(true); blockTree.FindHeader(deepAncestor.Hash!, BlockTreeLookupOptions.DoNotCreateLevelIfMissing).Returns(deepAncestor.Header); + IPruningConfig pruningConfig = Substitute.For(); + pruningConfig.PruningBoundary.Returns(pruningBoundary); + TaikoForkchoiceUpdatedHandler handler = new( blockTree, Substitute.For(), @@ -158,6 +166,7 @@ public async Task ShouldProceedWithReorg_Override_PreventsAncestorReorgSkip() Substitute.For(), Substitute.For(), new MergeConfig(), + pruningConfig, Substitute.For() ); diff --git a/src/Nethermind/Nethermind.Taiko/Rpc/TaikoForkchoiceUpdatedHandler.cs b/src/Nethermind/Nethermind.Taiko/Rpc/TaikoForkchoiceUpdatedHandler.cs index 0a0a80c4fb69..785a538be37b 100644 --- a/src/Nethermind/Nethermind.Taiko/Rpc/TaikoForkchoiceUpdatedHandler.cs +++ b/src/Nethermind/Nethermind.Taiko/Rpc/TaikoForkchoiceUpdatedHandler.cs @@ -10,6 +10,7 @@ using Nethermind.Core; using Nethermind.Core.Crypto; using Nethermind.Core.Specs; +using Nethermind.Db; using Nethermind.JsonRpc; using Nethermind.Logging; using Nethermind.Merge.Plugin; @@ -36,6 +37,7 @@ internal class TaikoForkchoiceUpdatedHandler( ISpecProvider specProvider, ISyncPeerPool syncPeerPool, IMergeConfig mergeConfig, + IPruningConfig pruningConfig, ILogManager logManager ) : ForkchoiceUpdatedHandler( blockTree, @@ -51,6 +53,7 @@ ILogManager logManager specProvider, syncPeerPool, mergeConfig, + pruningConfig, logManager) { protected override bool ShouldProceedWithReorg(BlockHeader newHeadHeader, ForkchoiceStateV1 forkchoiceState, From 71dc99f55d9d7055f05ea5bb9bc89a35954e42c0 Mon Sep 17 00:00:00 2001 From: stavrosvl7 Date: Sat, 25 Apr 2026 22:40:50 +0300 Subject: [PATCH 15/23] fixes --- .../Handlers/ForkchoiceUpdatedHandler.cs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/ForkchoiceUpdatedHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/ForkchoiceUpdatedHandler.cs index b842f464caad..dd193c80997d 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/ForkchoiceUpdatedHandler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/ForkchoiceUpdatedHandler.cs @@ -89,7 +89,7 @@ protected virtual bool ShouldProceedWithReorg(BlockHeader newHeadHeader, Forkcho } } - long reorgDepth = (_blockTree.Head?.Number ?? 0) - newHeadHeader.Number; + long reorgDepth = (_blockTree.Head?.Number ?? 0) - FindMainChainAncestorNumber(newHeadHeader); if (reorgDepth > _maxReorgDepth) { if (_logger.IsWarn) _logger.Warn($"Too deep reorg. Reorg depth: {reorgDepth}, limit: {_maxReorgDepth}. Request: {forkchoiceState}."); @@ -401,6 +401,23 @@ private bool IsInconsistent(BlockHeader? candidateHeader, BlockHeader newHeadHea return cursor.GetOrCalculateHash() != candidateHeader.GetOrCalculateHash(); } + // Returns the block number of newHeadHeader's closest ancestor (or itself) that is on the + // main chain. For a main-chain block this is newHeadHeader.Number itself. For a side-chain + // block this is the common ancestor's number — used to compute the true reorg depth. + // Returns 0 (genesis) when the parent chain cannot be fully walked, which is the most + // conservative fallback (maximises the reported reorg depth). + private long FindMainChainAncestorNumber(BlockHeader newHeadHeader) + { + BlockHeader cursor = newHeadHeader; + while (true) + { + if (_blockTree.IsMainChain(cursor)) return cursor.Number; + BlockHeader? parent = _blockTree.FindParentHeader(cursor, BlockTreeLookupOptions.TotalDifficultyNotNeeded); + if (parent is null) return 0; + cursor = parent; + } + } + private static Hash256 ResolveZeroHash(Hash256 hash, Hash256? knownHash) => hash == Keccak.Zero ? knownHash ?? Keccak.Zero : hash; From dd431e29428ae75d0586af2981f8764510ba3b47 Mon Sep 17 00:00:00 2001 From: stavrosvl7 Date: Sun, 26 Apr 2026 00:51:52 +0300 Subject: [PATCH 16/23] fix tests --- .../EngineModuleTests.V1.cs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs index 4736c8b77c78..fae5073a1194 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs @@ -569,13 +569,17 @@ public async Task forkchoiceUpdatedV1_should_update_safe_block_hash() public async Task forkchoiceUpdatedV1_WhenHeadIsAncestorWithinOrAtDepthLimit_ReorgsToAncestor(int chainLength) { // Spec: MUST support a reorg to a canonical ancestor no more than 32 blocks behind head. + // We submit blocks without finalizing them — reorging below finalized is a protocol violation + // that spec point 2 permits the client to skip. Keep finalized at zero so the reorg is allowed. using MergeTestBlockchain chain = await CreateBlockchain(); IEngineRpcModule rpc = chain.EngineRpcModule; - IReadOnlyList branch = await ProduceBranchV1(rpc, chain, chainLength, CreateParentBlockRequestOnHead(chain.BlockTree), setHead: true); + IReadOnlyList branch = await ProduceBranchV1(rpc, chain, chainLength, CreateParentBlockRequestOnHead(chain.BlockTree), setHead: false); Hash256 b1Hash = branch[0].BlockHash; Hash256 headHash = branch[chainLength - 1].BlockHash; + // Advance head to the last block without setting finalized + (await rpc.engine_forkchoiceUpdatedV1(new ForkchoiceStateV1(headHash, Keccak.Zero, Keccak.Zero))).Data.PayloadStatus.Status.Should().Be(PayloadStatus.Valid); chain.BlockTree.HeadHash.Should().Be(headHash, $"precondition: head is at H={chainLength}"); ForkchoiceStateV1 fcuToAncestor = new(b1Hash, Keccak.Zero, Keccak.Zero); @@ -1621,13 +1625,15 @@ public async Task forkchoiceUpdated_isInconsistent_takes_fast_path_when_candidat // Count FindHeader calls made by the repeated FCU only. Safe=Keccak.Zero skips its // ValidateBlockHash lookup, so the baseline calls are: 1 to resolve head, 1 for finalized - // validation, plus the IsInconsistent walk (1 under the optimization, 2 without). + // validation, 1 for ShouldProceedWithReorg's stored-finalized lookup, 1 for + // FindMainChainAncestorNumber (a3→a2, stops at first main-chain ancestor), plus the + // IsInconsistent walk (1 under the optimization — stops at a2 rather than continuing to a1). spy.ResetCounters(); ForkchoiceStateV1 repeated = new(headBlockHash: a3.BlockHash, finalizedBlockHash: a1.BlockHash, safeBlockHash: Keccak.Zero); ResultWrapper result = await rpc.engine_forkchoiceUpdatedV1(repeated); result.Data.PayloadStatus.Status.Should().Be(PayloadStatus.Valid); - spy.FindHeaderCalls.Should().Be(3, "walk must stop at the first main-chain ancestor (a2) rather than continue to a1"); + spy.FindHeaderCalls.Should().Be(5, "walk must stop at the first main-chain ancestor (a2) rather than continue to a1"); } [Test] From d87491f353d6525dc50c398df01b2990f4c692f2 Mon Sep 17 00:00:00 2001 From: stavrosvl7 Date: Sun, 26 Apr 2026 00:57:57 +0300 Subject: [PATCH 17/23] fixes --- .../Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs | 7 +++---- .../Handlers/ForkchoiceUpdatedHandler.cs | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs index fae5073a1194..d7fdffd3a35f 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs @@ -1625,15 +1625,14 @@ public async Task forkchoiceUpdated_isInconsistent_takes_fast_path_when_candidat // Count FindHeader calls made by the repeated FCU only. Safe=Keccak.Zero skips its // ValidateBlockHash lookup, so the baseline calls are: 1 to resolve head, 1 for finalized - // validation, 1 for ShouldProceedWithReorg's stored-finalized lookup, 1 for - // FindMainChainAncestorNumber (a3→a2, stops at first main-chain ancestor), plus the - // IsInconsistent walk (1 under the optimization — stops at a2 rather than continuing to a1). + // validation, plus the IsInconsistent walk (1 under the optimization, 2 without). + // ShouldProceedWithReorg is skipped because head is unchanged (blocks is null → no reorg). spy.ResetCounters(); ForkchoiceStateV1 repeated = new(headBlockHash: a3.BlockHash, finalizedBlockHash: a1.BlockHash, safeBlockHash: Keccak.Zero); ResultWrapper result = await rpc.engine_forkchoiceUpdatedV1(repeated); result.Data.PayloadStatus.Status.Should().Be(PayloadStatus.Valid); - spy.FindHeaderCalls.Should().Be(5, "walk must stop at the first main-chain ancestor (a2) rather than continue to a1"); + spy.FindHeaderCalls.Should().Be(3, "walk must stop at the first main-chain ancestor (a2) rather than continue to a1"); } [Test] diff --git a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/ForkchoiceUpdatedHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/ForkchoiceUpdatedHandler.cs index dd193c80997d..fbf4c49d023a 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/ForkchoiceUpdatedHandler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/ForkchoiceUpdatedHandler.cs @@ -263,7 +263,7 @@ protected virtual bool ShouldProceedWithReorg(BlockHeader newHeadHeader, Forkcho return ForkchoiceUpdatedV1Result.Error(setHeadErrorMsg, ErrorCodes.InvalidParams); } - if (!ShouldProceedWithReorg(newHeadHeader, forkchoiceState, out ResultWrapper? result)) + if (blocks is not null && !ShouldProceedWithReorg(newHeadHeader, forkchoiceState, out ResultWrapper? result)) { return result; } From 1f5fd2cff7221d0f72dfe0f994567ef883710f4e Mon Sep 17 00:00:00 2001 From: stavrosvl7 Date: Sun, 26 Apr 2026 01:35:17 +0300 Subject: [PATCH 18/23] claude review again --- .../BaseEngineModuleTests.cs | 4 +- .../EngineModuleTests.V1.cs | 65 ++++++++++++++----- .../Handlers/ForkchoiceUpdatedHandler.cs | 4 +- 3 files changed, 53 insertions(+), 20 deletions(-) diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Test/BaseEngineModuleTests.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/BaseEngineModuleTests.cs index 2819aaea6741..261269935904 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin.Test/BaseEngineModuleTests.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin.Test/BaseEngineModuleTests.cs @@ -106,7 +106,9 @@ protected async Task> ProduceBranchV1(IEngineRpc if (setHead) { Hash256 newHead = getPayloadResult.BlockHash; - ForkchoiceStateV1 forkchoiceStateV1 = new(newHead, newHead, newHead); + // Use Keccak.Zero for finalized/safe: ProduceBranchV1 is a chain-building helper, + // not a finality-setting one. Tests that need finalized must set it explicitly. + ForkchoiceStateV1 forkchoiceStateV1 = new(newHead, Keccak.Zero, Keccak.Zero); ResultWrapper setHeadResponse = await rpc.engine_forkchoiceUpdatedV1(forkchoiceStateV1); setHeadResponse.Data.PayloadStatus.Status.Should().Be(PayloadStatus.Valid); setHeadResponse.Data.PayloadId.Should().Be(null); diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs index d7fdffd3a35f..8c3ba57903bb 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs @@ -31,6 +31,7 @@ using Nethermind.JsonRpc.Test; using Nethermind.JsonRpc.Test.Modules; using Nethermind.Logging; +using Nethermind.Db; using Nethermind.Merge.Plugin.Data; using Nethermind.Merge.Plugin.Handlers; using Nethermind.Serialization.Json; @@ -564,13 +565,13 @@ public async Task forkchoiceUpdatedV1_should_update_safe_block_hash() } - [TestCase(3, TestName = "2 blocks behind head — within limit")] - [TestCase(33, TestName = "exactly 32 blocks behind head — boundary")] - public async Task forkchoiceUpdatedV1_WhenHeadIsAncestorWithinOrAtDepthLimit_ReorgsToAncestor(int chainLength) + [TestCase(3, TestName = "2 blocks behind head — within PruningBoundary")] + [TestCase(33, TestName = "32 blocks behind head — within PruningBoundary (default 64)")] + public async Task forkchoiceUpdatedV1_WhenHeadIsCanonicalAncestorWithinPruningBoundary_ReorgsToAncestor(int chainLength) { - // Spec: MUST support a reorg to a canonical ancestor no more than 32 blocks behind head. - // We submit blocks without finalizing them — reorging below finalized is a protocol violation - // that spec point 2 permits the client to skip. Keep finalized at zero so the reorg is allowed. + // Spec PR #786: MUST reorg to a canonical ancestor when reorg depth <= PruningBoundary (default 64). + // No finalized block is set — the MAY-skip (spec point 2) never fires; the reorg proceeds + // because depth < PruningBoundary, not because of any old "32-block limit". using MergeTestBlockchain chain = await CreateBlockchain(); IEngineRpcModule rpc = chain.EngineRpcModule; @@ -586,14 +587,16 @@ public async Task forkchoiceUpdatedV1_WhenHeadIsAncestorWithinOrAtDepthLimit_Reo ResultWrapper result = await rpc.engine_forkchoiceUpdatedV1(fcuToAncestor); result.Data.PayloadStatus.Status.Should().Be(PayloadStatus.Valid); - chain.BlockTree.HeadHash.Should().Be(b1Hash, $"head must reorg to b1 — depth is {chainLength - 1} blocks, within the 32-block limit"); + chain.BlockTree.HeadHash.Should().Be(b1Hash, $"head must reorg to b1 — depth {chainLength - 1} is within PruningBoundary (default 64)"); } [Test] - public async Task forkchoiceUpdatedV1_WhenHeadIsAncestorBeyondDepthLimit_SkipsUpdate() + public async Task forkchoiceUpdatedV1_WhenHeadIsAncestorOfFinalizedBlock_SkipsUpdate() { - // Spec: client MAY skip the update when headBlockHash is a canonical ancestor more than 32 blocks behind head. - // Nethermind skips in this case. Builds a chain of 34 blocks, then sends FCU to H=1 (33 blocks behind H=34). + // Spec PR #786 point 2: client MAY skip the update when headBlockHash is a valid ancestor of the + // latest known finalized block. ProduceBranchV1 with setHead:true finalizes each block via FCU, + // so after building 34 blocks the finalized hash is b34. FCU to b1 (H=1 < H=34, canonical) + // triggers the MAY-skip — not any depth limit. using MergeTestBlockchain chain = await CreateBlockchain(); IEngineRpcModule rpc = chain.EngineRpcModule; @@ -602,23 +605,25 @@ public async Task forkchoiceUpdatedV1_WhenHeadIsAncestorBeyondDepthLimit_SkipsUp Hash256 b34Hash = branch[33].BlockHash; chain.BlockTree.HeadHash.Should().Be(b34Hash, "precondition: head is at H=34"); + chain.BlockTree.FinalizedHash.Should().Be(b34Hash, "precondition: b34 is finalized after setHead:true branch"); - ForkchoiceStateV1 fcuToDeepAncestor = new(b1Hash, Keccak.Zero, Keccak.Zero); - ResultWrapper result = await rpc.engine_forkchoiceUpdatedV1(fcuToDeepAncestor); + ForkchoiceStateV1 fcuToAncestorOfFinalized = new(b1Hash, Keccak.Zero, Keccak.Zero); + ResultWrapper result = await rpc.engine_forkchoiceUpdatedV1(fcuToAncestorOfFinalized); result.Data.PayloadStatus.Status.Should().Be(PayloadStatus.Valid); result.Data.PayloadStatus.LatestValidHash.Should().Be(b1Hash, "spec mandates latestValidHash == forkchoiceState.headBlockHash when skipping"); result.Data.PayloadId.Should().BeNull("spec mandates payloadId: null when skipping"); - chain.BlockTree.HeadHash.Should().Be(b34Hash, "Nethermind skips the update — ancestor is beyond the 32-block depth limit, skip is permitted by spec"); + chain.BlockTree.HeadHash.Should().Be(b34Hash, "Nethermind skips the update — b1 is a canonical ancestor of the finalized block, skip is permitted by spec"); } [Test] public async Task forkchoiceUpdatedV1_WhenHeadIsOnDifferentBranch_ReorgsRegardlessOfDepth() { - // Spec: the 32-block depth limit only applies to ancestors of the canonical chain. - // A block on a different (non-canonical) branch must always trigger a reorg — no depth limit. - // Builds a canonical chain of 34 blocks, then a side branch of 1 block off genesis. - // The side block is 34 levels "behind" but is NOT a canonical ancestor — it's on a fork. + // Spec PR #786: the MAY-skip (spec point 2) only fires when headBlockHash is a canonical ancestor + // of the finalized block. A non-canonical (fork) block is NOT eligible for the skip. + // FindMainChainAncestorNumber returns 0 (genesis) for side-chain blocks, giving a large reported + // depth, but here depth (34) < PruningBoundary (64) so -38006 doesn't fire either — the reorg + // always proceeds. Builds a canonical chain of 34 blocks, then a side block off genesis. using MergeTestBlockchain chain = await CreateBlockchain(); IEngineRpcModule rpc = chain.EngineRpcModule; @@ -641,7 +646,31 @@ public async Task forkchoiceUpdatedV1_WhenHeadIsOnDifferentBranch_ReorgsRegardle ResultWrapper result = await rpc.engine_forkchoiceUpdatedV1(fcuToSide); result.Data.PayloadStatus.Status.Should().Be(PayloadStatus.Valid); - chain.BlockTree.HeadHash.Should().Be(sideHash, "different-branch FCU must always reorg — depth limit does not apply"); + chain.BlockTree.HeadHash.Should().Be(sideHash, "different-branch FCU must always reorg — MAY-skip only applies to canonical ancestors of the finalized block"); + } + + [Test] + public async Task forkchoiceUpdatedV1_WhenReorgDepthExceedsPruningBoundary_ReturnsTooDeepReorg() + { + // Spec PR #786 point 6: MUST return -38006 when reorg depth > PruningBoundary. + // Configure PruningBoundary=2, build a 5-block chain (no finalized), then FCU to H=1. + // reorgDepth = head(5) - commonAncestor(1) = 4 > 2 → -38006. + using MergeTestBlockchain chain = await CreateBlockchain(configurer: b => + b.AddSingleton(new PruningConfig { PruningBoundary = 2 })); + IEngineRpcModule rpc = chain.EngineRpcModule; + + IReadOnlyList branch = await ProduceBranchV1(rpc, chain, 5, CreateParentBlockRequestOnHead(chain.BlockTree), setHead: false); + Hash256 b1Hash = branch[0].BlockHash; + Hash256 b5Hash = branch[4].BlockHash; + + (await rpc.engine_forkchoiceUpdatedV1(new ForkchoiceStateV1(b5Hash, Keccak.Zero, Keccak.Zero))).Data.PayloadStatus.Status.Should().Be(PayloadStatus.Valid); + chain.BlockTree.HeadHash.Should().Be(b5Hash, "precondition: head is at H=5"); + + ForkchoiceStateV1 fcuTooDeep = new(b1Hash, Keccak.Zero, Keccak.Zero); + ResultWrapper result = await rpc.engine_forkchoiceUpdatedV1(fcuTooDeep); + + result.ErrorCode.Should().Be(MergeErrorCodes.TooDeepReorg, "reorg depth 4 exceeds PruningBoundary 2 — must return -38006"); + chain.BlockTree.HeadHash.Should().Be(b5Hash, "head must not change when -38006 is returned"); } [Test] diff --git a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/ForkchoiceUpdatedHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/ForkchoiceUpdatedHandler.cs index fbf4c49d023a..da1ce4d7e149 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/ForkchoiceUpdatedHandler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/ForkchoiceUpdatedHandler.cs @@ -57,6 +57,8 @@ public class ForkchoiceUpdatedHandler( private readonly IPoSSwitcher _poSSwitcher = poSSwitcher ?? throw new ArgumentNullException(nameof(poSSwitcher)); private readonly ILogger _logger = logManager.GetClassLogger(); private readonly bool _simulateBlockProduction = mergeConfig.SimulateBlockProduction; + // Spec point 6: implementation-specific limit for -38006. Matches pruning boundary + // because the client cannot serve state older than that. See execution-apis/pull/786. private readonly int _maxReorgDepth = pruningConfig.PruningBoundary; public async Task> Handle(ForkchoiceStateV1 forkchoiceState, PayloadAttributes? payloadAttributes, int version) @@ -81,7 +83,7 @@ protected virtual bool ShouldProceedWithReorg(BlockHeader newHeadHeader, Forkcho { if (_logger.IsWarn) _logger.Warn($"Known finalized hash {knownFinalizedHash} has no header — cannot check spec point 2 ancestry. Falling back to depth limit."); } - else if (newHeadHeader.Number < knownFinalizedHeader.Number && _blockTree.IsMainChain(newHeadHeader)) + else if (newHeadHeader.Number <= knownFinalizedHeader.Number && _blockTree.IsMainChain(newHeadHeader)) { if (_logger.IsInfo) _logger.Info($"Valid. ForkChoiceUpdated skipped - head is a valid ancestor of the latest known finalized block."); errorResult = ForkchoiceUpdatedV1Result.Valid(null, forkchoiceState.HeadBlockHash); From 830c578eb2388140e2141b4639313fa83f48d854 Mon Sep 17 00:00:00 2001 From: stavrosvl7 Date: Sun, 26 Apr 2026 01:38:55 +0300 Subject: [PATCH 19/23] fix tests --- .../EngineModuleTests.V1.cs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs index 8c3ba57903bb..cf7803141dbd 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs @@ -594,18 +594,19 @@ public async Task forkchoiceUpdatedV1_WhenHeadIsCanonicalAncestorWithinPruningBo public async Task forkchoiceUpdatedV1_WhenHeadIsAncestorOfFinalizedBlock_SkipsUpdate() { // Spec PR #786 point 2: client MAY skip the update when headBlockHash is a valid ancestor of the - // latest known finalized block. ProduceBranchV1 with setHead:true finalizes each block via FCU, - // so after building 34 blocks the finalized hash is b34. FCU to b1 (H=1 < H=34, canonical) - // triggers the MAY-skip — not any depth limit. + // latest known finalized block. Build 34 blocks, explicitly finalize b34, then FCU to b1 + // (H=1 <= H=34, canonical) — the MAY-skip fires, not any depth limit. using MergeTestBlockchain chain = await CreateBlockchain(); IEngineRpcModule rpc = chain.EngineRpcModule; - IReadOnlyList branch = await ProduceBranchV1(rpc, chain, 34, CreateParentBlockRequestOnHead(chain.BlockTree), setHead: true); + IReadOnlyList branch = await ProduceBranchV1(rpc, chain, 34, CreateParentBlockRequestOnHead(chain.BlockTree), setHead: false); Hash256 b1Hash = branch[0].BlockHash; Hash256 b34Hash = branch[33].BlockHash; + // Set head to b34 and explicitly finalize it + (await rpc.engine_forkchoiceUpdatedV1(new ForkchoiceStateV1(b34Hash, b34Hash, b34Hash))).Data.PayloadStatus.Status.Should().Be(PayloadStatus.Valid); chain.BlockTree.HeadHash.Should().Be(b34Hash, "precondition: head is at H=34"); - chain.BlockTree.FinalizedHash.Should().Be(b34Hash, "precondition: b34 is finalized after setHead:true branch"); + chain.BlockTree.FinalizedHash.Should().Be(b34Hash, "precondition: b34 is finalized"); ForkchoiceStateV1 fcuToAncestorOfFinalized = new(b1Hash, Keccak.Zero, Keccak.Zero); ResultWrapper result = await rpc.engine_forkchoiceUpdatedV1(fcuToAncestorOfFinalized); From 0bccb0649f8c9d6c7c689d61b5635aae0e9bc191 Mon Sep 17 00:00:00 2001 From: stavrosvl7 Date: Sun, 26 Apr 2026 01:52:02 +0300 Subject: [PATCH 20/23] test fix --- .../Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs index cf7803141dbd..d3b9c6f5e06f 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs @@ -31,7 +31,6 @@ using Nethermind.JsonRpc.Test; using Nethermind.JsonRpc.Test.Modules; using Nethermind.Logging; -using Nethermind.Db; using Nethermind.Merge.Plugin.Data; using Nethermind.Merge.Plugin.Handlers; using Nethermind.Serialization.Json; @@ -657,7 +656,7 @@ public async Task forkchoiceUpdatedV1_WhenReorgDepthExceedsPruningBoundary_Retur // Configure PruningBoundary=2, build a 5-block chain (no finalized), then FCU to H=1. // reorgDepth = head(5) - commonAncestor(1) = 4 > 2 → -38006. using MergeTestBlockchain chain = await CreateBlockchain(configurer: b => - b.AddSingleton(new PruningConfig { PruningBoundary = 2 })); + b.Intercept(cfg => cfg.PruningBoundary = 2)); IEngineRpcModule rpc = chain.EngineRpcModule; IReadOnlyList branch = await ProduceBranchV1(rpc, chain, 5, CreateParentBlockRequestOnHead(chain.BlockTree), setHead: false); From 6fcc136f54a8acc1b1266103230b772f302f7c09 Mon Sep 17 00:00:00 2001 From: stavrosvl7 Date: Sun, 26 Apr 2026 02:11:12 +0300 Subject: [PATCH 21/23] fixes --- .../EngineModuleTests.V1.cs | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs index d3b9c6f5e06f..a2d15e130cdb 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs @@ -652,25 +652,24 @@ public async Task forkchoiceUpdatedV1_WhenHeadIsOnDifferentBranch_ReorgsRegardle [Test] public async Task forkchoiceUpdatedV1_WhenReorgDepthExceedsPruningBoundary_ReturnsTooDeepReorg() { - // Spec PR #786 point 6: MUST return -38006 when reorg depth > PruningBoundary. - // Configure PruningBoundary=2, build a 5-block chain (no finalized), then FCU to H=1. - // reorgDepth = head(5) - commonAncestor(1) = 4 > 2 → -38006. - using MergeTestBlockchain chain = await CreateBlockchain(configurer: b => - b.Intercept(cfg => cfg.PruningBoundary = 2)); + // Spec PR #786 point 6: MUST return -38006 when reorg depth > PruningBoundary (default 64). + // Build 66 blocks (no finalized), then FCU to H=1. + // reorgDepth = head(66) - commonAncestor(1) = 65 > 64 → -38006. + using MergeTestBlockchain chain = await CreateBlockchain(); IEngineRpcModule rpc = chain.EngineRpcModule; - IReadOnlyList branch = await ProduceBranchV1(rpc, chain, 5, CreateParentBlockRequestOnHead(chain.BlockTree), setHead: false); + IReadOnlyList branch = await ProduceBranchV1(rpc, chain, 66, CreateParentBlockRequestOnHead(chain.BlockTree), setHead: false); Hash256 b1Hash = branch[0].BlockHash; - Hash256 b5Hash = branch[4].BlockHash; + Hash256 b66Hash = branch[65].BlockHash; - (await rpc.engine_forkchoiceUpdatedV1(new ForkchoiceStateV1(b5Hash, Keccak.Zero, Keccak.Zero))).Data.PayloadStatus.Status.Should().Be(PayloadStatus.Valid); - chain.BlockTree.HeadHash.Should().Be(b5Hash, "precondition: head is at H=5"); + (await rpc.engine_forkchoiceUpdatedV1(new ForkchoiceStateV1(b66Hash, Keccak.Zero, Keccak.Zero))).Data.PayloadStatus.Status.Should().Be(PayloadStatus.Valid); + chain.BlockTree.HeadHash.Should().Be(b66Hash, "precondition: head is at H=66"); ForkchoiceStateV1 fcuTooDeep = new(b1Hash, Keccak.Zero, Keccak.Zero); ResultWrapper result = await rpc.engine_forkchoiceUpdatedV1(fcuTooDeep); - result.ErrorCode.Should().Be(MergeErrorCodes.TooDeepReorg, "reorg depth 4 exceeds PruningBoundary 2 — must return -38006"); - chain.BlockTree.HeadHash.Should().Be(b5Hash, "head must not change when -38006 is returned"); + result.ErrorCode.Should().Be(MergeErrorCodes.TooDeepReorg, "reorg depth 65 exceeds default PruningBoundary 64 — must return -38006"); + chain.BlockTree.HeadHash.Should().Be(b66Hash, "head must not change when -38006 is returned"); } [Test] From 010547be9ef97dcf853a2b239b7b0bb7a210879a Mon Sep 17 00:00:00 2001 From: stavrosvl7 Date: Sun, 26 Apr 2026 03:22:39 +0300 Subject: [PATCH 22/23] test fixes --- .../EngineModuleTests.V1.cs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs index a2d15e130cdb..0014e2374e8c 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs @@ -14,6 +14,7 @@ using FluentAssertions; using Nethermind.Blockchain; using Nethermind.Blockchain.Find; +using Nethermind.Blockchain.Synchronization; using Nethermind.Consensus.Processing; using Nethermind.Consensus.Producers; using Nethermind.Core; @@ -652,10 +653,15 @@ public async Task forkchoiceUpdatedV1_WhenHeadIsOnDifferentBranch_ReorgsRegardle [Test] public async Task forkchoiceUpdatedV1_WhenReorgDepthExceedsPruningBoundary_ReturnsTooDeepReorg() { - // Spec PR #786 point 6: MUST return -38006 when reorg depth > PruningBoundary (default 64). - // Build 66 blocks (no finalized), then FCU to H=1. - // reorgDepth = head(66) - commonAncestor(1) = 65 > 64 → -38006. - using MergeTestBlockchain chain = await CreateBlockchain(); + // Spec PR #786 point 6: MUST return -38006 when reorg depth > client-specific limit. + // Disable SnapServing so PruningTrieStateFactory.AdviseConfig doesn't bump the boundary + // to SnapServingMaxDepth=128 (TestBlockchain wiring auto-flips SnapServingEnabled on for + // HalfPath key schemes via WorldStateModule; setting it to false here keeps the auto-flip + // a no-op since `|=` only fires when the value is null). + // AdviseConfig also enforces a hard floor of 64 on PruningBoundary, so the smallest + // chain that exercises the -38006 path is 66 blocks: reorgDepth = 66 - 1 = 65 > 64 → -38006. + using MergeTestBlockchain chain = await CreateBlockchain(configurer: b => b + .Intercept(cfg => cfg.SnapServingEnabled = false)); IEngineRpcModule rpc = chain.EngineRpcModule; IReadOnlyList branch = await ProduceBranchV1(rpc, chain, 66, CreateParentBlockRequestOnHead(chain.BlockTree), setHead: false); @@ -663,12 +669,13 @@ public async Task forkchoiceUpdatedV1_WhenReorgDepthExceedsPruningBoundary_Retur Hash256 b66Hash = branch[65].BlockHash; (await rpc.engine_forkchoiceUpdatedV1(new ForkchoiceStateV1(b66Hash, Keccak.Zero, Keccak.Zero))).Data.PayloadStatus.Status.Should().Be(PayloadStatus.Valid); + chain.BlockTree.HeadHash.Should().Be(b66Hash, "precondition: head is at H=66"); ForkchoiceStateV1 fcuTooDeep = new(b1Hash, Keccak.Zero, Keccak.Zero); ResultWrapper result = await rpc.engine_forkchoiceUpdatedV1(fcuTooDeep); - result.ErrorCode.Should().Be(MergeErrorCodes.TooDeepReorg, "reorg depth 65 exceeds default PruningBoundary 64 — must return -38006"); + result.ErrorCode.Should().Be(MergeErrorCodes.TooDeepReorg, "reorg depth 65 exceeds PruningBoundary 64 — must return -38006"); chain.BlockTree.HeadHash.Should().Be(b66Hash, "head must not change when -38006 is returned"); } From 540e4a2fd1774064c1cff747118a3ae4a7644be0 Mon Sep 17 00:00:00 2001 From: stavrosvl7 Date: Sun, 26 Apr 2026 04:52:49 +0300 Subject: [PATCH 23/23] minor fixes --- .../Nethermind.Merge.Plugin/BlockTreeExtensions.cs | 9 +++++++++ .../Handlers/ForkchoiceUpdatedHandler.cs | 7 ++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/Nethermind/Nethermind.Merge.Plugin/BlockTreeExtensions.cs b/src/Nethermind/Nethermind.Merge.Plugin/BlockTreeExtensions.cs index 4d0ce13db71b..2e271d47265c 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/BlockTreeExtensions.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/BlockTreeExtensions.cs @@ -8,6 +8,15 @@ namespace Nethermind.Merge.Plugin; public static class BlockTreeExtensions { + /// + /// Returns true when belongs to the canonical chain + /// and is at or behind the current head — i.e. an unprocessed FCU to it can be safely + /// answered VALID without reorging. + /// + /// The block tree. + /// Header to test for canonical-ancestor membership. + /// true if is on the main chain and its + /// number does not exceed the current head's number; otherwise false. public static bool IsOnMainChainBehindOrEqualHead(this IBlockTree blockTree, BlockHeader header) => header.Number <= (blockTree.Head?.Number ?? 0) && blockTree.IsMainChain(header); } diff --git a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/ForkchoiceUpdatedHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/ForkchoiceUpdatedHandler.cs index da1ce4d7e149..bb11c07fb0ee 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/ForkchoiceUpdatedHandler.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/ForkchoiceUpdatedHandler.cs @@ -59,7 +59,7 @@ public class ForkchoiceUpdatedHandler( private readonly bool _simulateBlockProduction = mergeConfig.SimulateBlockProduction; // Spec point 6: implementation-specific limit for -38006. Matches pruning boundary // because the client cannot serve state older than that. See execution-apis/pull/786. - private readonly int _maxReorgDepth = pruningConfig.PruningBoundary; + private readonly IPruningConfig _pruningConfig = pruningConfig; public async Task> Handle(ForkchoiceStateV1 forkchoiceState, PayloadAttributes? payloadAttributes, int version) { @@ -92,9 +92,10 @@ protected virtual bool ShouldProceedWithReorg(BlockHeader newHeadHeader, Forkcho } long reorgDepth = (_blockTree.Head?.Number ?? 0) - FindMainChainAncestorNumber(newHeadHeader); - if (reorgDepth > _maxReorgDepth) + int maxReorgDepth = _pruningConfig.PruningBoundary; + if (reorgDepth > maxReorgDepth) { - if (_logger.IsWarn) _logger.Warn($"Too deep reorg. Reorg depth: {reorgDepth}, limit: {_maxReorgDepth}. Request: {forkchoiceState}."); + if (_logger.IsWarn) _logger.Warn($"Too deep reorg. Reorg depth: {reorgDepth}, limit: {maxReorgDepth}. Request: {forkchoiceState}."); errorResult = ResultWrapper.Fail("Too deep reorg", MergeErrorCodes.TooDeepReorg); return false; }