@@ -563,6 +563,170 @@ public async Task forkchoiceUpdatedV1_should_update_safe_block_hash()
563563 }
564564
565565
566+ [ Test ]
567+ public async Task forkchoiceUpdatedV1_WhenHeadIsAncestorWithinDepthLimit_ReorgsToAncestor ( )
568+ {
569+ // FCU to an ancestor within 32 blocks must execute the reorg, not skip it.
570+ // Builds a chain: genesis → b1 → b2 → b3 (head at H=3), then sends FCU(b1).
571+ // b1 is 2 blocks behind head — within the 32-block limit — so head must move back to b1.
572+ using MergeTestBlockchain chain = await CreateBlockchain ( ) ;
573+ IEngineRpcModule rpc = chain . EngineRpcModule ;
574+
575+ IReadOnlyList < ExecutionPayload > branch = await ProduceBranchV1 ( rpc , chain , 3 , CreateParentBlockRequestOnHead ( chain . BlockTree ) , setHead : true ) ;
576+ Hash256 b1Hash = branch [ 0 ] . BlockHash ;
577+ Hash256 b3Hash = branch [ 2 ] . BlockHash ;
578+
579+ chain . BlockTree . HeadHash . Should ( ) . Be ( b3Hash , "precondition: head is at H=3" ) ;
580+
581+ ForkchoiceStateV1 fcuToAncestor = new ( b1Hash , Keccak . Zero , Keccak . Zero ) ;
582+ ResultWrapper < ForkchoiceUpdatedV1Result > result = await rpc . engine_forkchoiceUpdatedV1 ( fcuToAncestor ) ;
583+
584+ result . Data . PayloadStatus . Status . Should ( ) . Be ( PayloadStatus . Valid ) ;
585+ chain . BlockTree . HeadHash . Should ( ) . Be ( b1Hash , "head must reorg back to b1 — within 32-block ancestor depth limit" ) ;
586+ }
587+
588+ [ Test ]
589+ public async Task forkchoiceUpdatedV1_WhenHeadIsAncestorBeyondDepthLimit_SkipsUpdate ( )
590+ {
591+ // FCU to an ancestor MORE than 32 blocks behind head must be skipped (treated as already canonical).
592+ // Builds a chain of 34 blocks, then sends FCU to block at H=1 (33 blocks behind head at H=34).
593+ using MergeTestBlockchain chain = await CreateBlockchain ( ) ;
594+ IEngineRpcModule rpc = chain . EngineRpcModule ;
595+
596+ IReadOnlyList < ExecutionPayload > branch = await ProduceBranchV1 ( rpc , chain , 34 , CreateParentBlockRequestOnHead ( chain . BlockTree ) , setHead : true ) ;
597+ Hash256 b1Hash = branch [ 0 ] . BlockHash ;
598+ Hash256 b34Hash = branch [ 33 ] . BlockHash ;
599+
600+ chain . BlockTree . HeadHash . Should ( ) . Be ( b34Hash , "precondition: head is at H=34" ) ;
601+
602+ ForkchoiceStateV1 fcuToDeepAncestor = new ( b1Hash , Keccak . Zero , Keccak . Zero ) ;
603+ ResultWrapper < ForkchoiceUpdatedV1Result > result = await rpc . engine_forkchoiceUpdatedV1 ( fcuToDeepAncestor ) ;
604+
605+ result . Data . PayloadStatus . Status . Should ( ) . Be ( PayloadStatus . Valid ) ;
606+ chain . BlockTree . HeadHash . Should ( ) . Be ( b34Hash , "head must stay at H=34 — ancestor is beyond the 32-block depth limit" ) ;
607+ }
608+
609+ [ Test ]
610+ public async Task forkchoiceUpdatedV1_WhenHeadIsAncestorAtExactDepthLimit_ReorgsToAncestor ( )
611+ {
612+ // FCU to an ancestor exactly 32 blocks behind head must execute the reorg (boundary condition).
613+ // Builds 33 blocks. b1 is exactly 32 blocks behind H=33 — must reorg.
614+ using MergeTestBlockchain chain = await CreateBlockchain ( ) ;
615+ IEngineRpcModule rpc = chain . EngineRpcModule ;
616+
617+ IReadOnlyList < ExecutionPayload > branch = await ProduceBranchV1 ( rpc , chain , 33 , CreateParentBlockRequestOnHead ( chain . BlockTree ) , setHead : true ) ;
618+ Hash256 b1Hash = branch [ 0 ] . BlockHash ;
619+ Hash256 b33Hash = branch [ 32 ] . BlockHash ;
620+
621+ chain . BlockTree . HeadHash . Should ( ) . Be ( b33Hash , "precondition: head is at H=33" ) ;
622+
623+ ForkchoiceStateV1 fcuToAncestorAtLimit = new ( b1Hash , Keccak . Zero , Keccak . Zero ) ;
624+ ResultWrapper < ForkchoiceUpdatedV1Result > result = await rpc . engine_forkchoiceUpdatedV1 ( fcuToAncestorAtLimit ) ;
625+
626+ result . Data . PayloadStatus . Status . Should ( ) . Be ( PayloadStatus . Valid ) ;
627+ chain . BlockTree . HeadHash . Should ( ) . Be ( b1Hash , "head must reorg to b1 — exactly at the 32-block depth limit" ) ;
628+ }
629+
630+ [ Test ]
631+ public async Task forkchoiceUpdatedV1_WhenHeadIsOnDifferentBranch_ReorgsRegardlessOfDepth ( )
632+ {
633+ // Spec: the 32-block depth limit only applies to ancestors of the canonical chain.
634+ // A block on a different (non-canonical) branch must always trigger a reorg — no depth limit.
635+ // Builds a canonical chain of 34 blocks, then a side branch of 1 block off genesis.
636+ // The side block is 34 levels "behind" but is NOT a canonical ancestor — it's on a fork.
637+ using MergeTestBlockchain chain = await CreateBlockchain ( ) ;
638+ IEngineRpcModule rpc = chain . EngineRpcModule ;
639+
640+ // Build canonical chain: genesis → b1 → b2 → ... → b34 (head at H=34)
641+ IReadOnlyList < ExecutionPayload > canonical = await ProduceBranchV1 ( rpc , chain , 34 , CreateParentBlockRequestOnHead ( chain . BlockTree ) , setHead : true ) ;
642+ Hash256 b34Hash = canonical [ 33 ] . BlockHash ;
643+ chain . BlockTree . HeadHash . Should ( ) . Be ( b34Hash , "precondition: canonical head is at H=34" ) ;
644+
645+ // Build a side block off genesis (H=1, different branch)
646+ BlockHeader genesis = chain . BlockTree . Genesis ! ;
647+ ExecutionPayload genesisAsParent = new ExecutionPayload
648+ {
649+ BlockNumber = genesis . Number ,
650+ BlockHash = genesis . Hash ! ,
651+ StateRoot = genesis . StateRoot ! ,
652+ ReceiptsRoot = genesis . ReceiptsRoot ! ,
653+ GasLimit = genesis . GasLimit ,
654+ Timestamp = genesis . Timestamp ,
655+ BaseFeePerGas = genesis . BaseFeePerGas ,
656+ } ;
657+ ExecutionPayload sideBlock = CreateBlockRequest ( chain , genesisAsParent , TestItem . AddressA ) ;
658+ await rpc . engine_newPayloadV1 ( sideBlock ) ;
659+ Hash256 sideHash = sideBlock . BlockHash ;
660+ chain . BlockTree . IsMainChain ( chain . BlockTree . FindHeader ( sideHash , BlockTreeLookupOptions . None ) ! ) . Should ( ) . BeFalse ( "precondition: side block is not on canonical chain" ) ;
661+
662+ // FCU to the side block — it's on a different branch, so it must reorg regardless of depth
663+ ForkchoiceStateV1 fcuToSide = new ( sideHash , Keccak . Zero , Keccak . Zero ) ;
664+ ResultWrapper < ForkchoiceUpdatedV1Result > result = await rpc . engine_forkchoiceUpdatedV1 ( fcuToSide ) ;
665+
666+ result . Data . PayloadStatus . Status . Should ( ) . Be ( PayloadStatus . Valid ) ;
667+ chain . BlockTree . HeadHash . Should ( ) . Be ( sideHash , "different-branch FCU must always reorg — depth limit does not apply" ) ;
668+ }
669+
670+ [ Test ]
671+ public async Task forkchoiceUpdatedV1_WhenZeroFinalizedAndSafeHash_ReturnsValidWithoutError ( )
672+ {
673+ // Spec: zero safeBlockHash and finalizedBlockHash mean "unknown" — must not return -38002.
674+ // Models CL checkpoint-syncing from a non-finalized state where safe/finalized are unknown.
675+ using MergeTestBlockchain chain = await CreateBlockchain ( ) ;
676+ IEngineRpcModule rpc = chain . EngineRpcModule ;
677+
678+ IReadOnlyList < ExecutionPayload > branch = await ProduceBranchV1 ( rpc , chain , 3 , CreateParentBlockRequestOnHead ( chain . BlockTree ) , setHead : true ) ;
679+ Hash256 headHash = branch [ 2 ] . BlockHash ;
680+
681+ ForkchoiceStateV1 fcuWithUnknownFinality = new ( headHash , Keccak . Zero , Keccak . Zero ) ;
682+ ResultWrapper < ForkchoiceUpdatedV1Result > result = await rpc . engine_forkchoiceUpdatedV1 ( fcuWithUnknownFinality ) ;
683+
684+ result . ErrorCode . Should ( ) . Be ( 0 , "zero safe/finalized hashes must not produce an error" ) ;
685+ result . Data . PayloadStatus . Status . Should ( ) . Be ( PayloadStatus . Valid ) ;
686+ chain . BlockTree . HeadHash . Should ( ) . Be ( headHash ) ;
687+ }
688+
689+ [ Test ]
690+ public async Task forkchoiceUpdatedV1_WhenZeroFinalizedHash_PreservesKnownFinalizedHash ( )
691+ {
692+ // Spec PR #760: when finalizedBlockHash is zero, client MUST use the latest known finalized hash — not overwrite it with zero.
693+ using MergeTestBlockchain chain = await CreateBlockchain ( ) ;
694+ IEngineRpcModule rpc = chain . EngineRpcModule ;
695+
696+ IReadOnlyList < ExecutionPayload > branch = await ProduceBranchV1 ( rpc , chain , 2 , CreateParentBlockRequestOnHead ( chain . BlockTree ) , setHead : true ) ;
697+ Hash256 b1Hash = branch [ 0 ] . BlockHash ;
698+ Hash256 b2Hash = branch [ 1 ] . BlockHash ;
699+
700+ // First FCU: finalize b1
701+ ForkchoiceStateV1 fcuWithFinalized = new ( b2Hash , b1Hash , b1Hash ) ;
702+ await rpc . engine_forkchoiceUpdatedV1 ( fcuWithFinalized ) ;
703+ chain . BlockTree . FinalizedHash . Should ( ) . Be ( b1Hash , "precondition: b1 is finalized after first FCU" ) ;
704+
705+ // Second FCU: zero finalizedBlockHash — must preserve b1 as finalized
706+ ForkchoiceStateV1 fcuWithZeroFinalized = new ( b2Hash , Keccak . Zero , Keccak . Zero ) ;
707+ ResultWrapper < ForkchoiceUpdatedV1Result > result = await rpc . engine_forkchoiceUpdatedV1 ( fcuWithZeroFinalized ) ;
708+
709+ result . Data . PayloadStatus . Status . Should ( ) . Be ( PayloadStatus . Valid ) ;
710+ chain . BlockTree . FinalizedHash . Should ( ) . Be ( b1Hash , "zero finalizedBlockHash must preserve the previously known finalized hash" ) ;
711+ }
712+
713+ [ Test ]
714+ public async Task forkchoiceUpdatedV1_WhenNonZeroUnknownFinalizedHash_ReturnsInvalidForkchoiceState ( )
715+ {
716+ // Spec: -38002 must only fire for non-zero hashes that are unknown, not for zero hashes.
717+ using MergeTestBlockchain chain = await CreateBlockchain ( ) ;
718+ IEngineRpcModule rpc = chain . EngineRpcModule ;
719+
720+ IReadOnlyList < ExecutionPayload > branch = await ProduceBranchV1 ( rpc , chain , 1 , CreateParentBlockRequestOnHead ( chain . BlockTree ) , setHead : true ) ;
721+ Hash256 headHash = branch [ 0 ] . BlockHash ;
722+
723+ ForkchoiceStateV1 fcuWithUnknownFinalized = new ( headHash , TestItem . KeccakA , Keccak . Zero ) ;
724+ ResultWrapper < ForkchoiceUpdatedV1Result > result = await rpc . engine_forkchoiceUpdatedV1 ( fcuWithUnknownFinalized ) ;
725+
726+ result . ErrorCode . Should ( ) . Be ( MergeErrorCodes . InvalidForkchoiceState ,
727+ "non-zero unknown finalizedBlockHash must return -38002" ) ;
728+ }
729+
566730 [ Test ]
567731 public async Task forkchoiceUpdatedV1_should_work_with_zero_keccak_as_safe_block ( )
568732 {
0 commit comments