Skip to content

fix(blockvalidation,subtreevalidation): reject forks below a passed checkpoint and keep older-era side blocks out of block assembly - #2069

Open
icellan wants to merge 6 commits into
bsv-blockchain:mainfrom
icellan:claude-issue-4940
Open

icellan wants to merge 6 commits into
bsv-blockchain:mainfrom
icellan:claude-issue-4940

Conversation

@icellan

@icellan icellan commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Fixes bitcoin-sv/teranode#4940

Problem

A side block is validated at its own height. Below Genesis (or Chronicle) its transactions get the older script rules. When the node is RUNNING those transactions go to block assembly, which mines them at tip+1 without re-checking scripts. Peers that already hold the stored verdict skip the re-check as well. A cheap fork deep below the tip can therefore put transactions into our template that the rest of the network rejects.

Change

  1. Checkpoint fork rule (ValidateBlockWithOptions, right after the exact checkpoint hash check). A block whose height is below a checkpoint the tip has already reached is rejected as BlockInvalid with reason bad-fork-prior-to-checkpoint, the same rule as bitcoin-sv. Checkpoints above the tip don't count, so initial sync is unaffected. The tip is read only for blocks at or below the highest checkpoint, so blocks above it cost no extra call. On the non-optimistic path the rule runs again just before the block is stored, under its own context, in case the tip passes a checkpoint while the subtrees validate. The rule itself is model.ForkBelowCheckpoint, applied by rejectForkBelowCheckpoint.
  2. Block assembly takes txs only from same-era blocks (feedsBlockAssembly, used by CheckBlockSubtrees and the legacy CheckSubtree path). In RUNNING, a block's transactions go to block assembly only when the block is not above tip+1 and both heights fall under the same transaction rules (validator.SameTxRules): BDK computes the same consensus script flags at both, and no BIP68 activation (CSVHeight) lies between them. The flags come from BDK, not a list of chaincfg heights: BDK enables P2SH at a height chaincfg does not carry, UAHF and DAA one block after their fork heights, and its STN BIP65/66 heights differ from chaincfg. A same-era block that does not extend the tip still feeds, so the txs of a competitor that loses stay in the template. A first version fed only blocks extending the tip, which stranded them: a resubmission hits ErrTxExists and is not re-added, and a child arriving later could enter the template without its parent.

Behaviour changes

  • A block below a checkpoint the tip has passed now fails validation (nothing persisted, peer struck); it used to be validated.
  • A block above tip+1, or under different transaction rules from tip+1, no longer feeds block assembly.
  • In RUNNING, each block makes one extra GetBestBlockHeader call; on the legacy path it is one per subtree. Below the highest checkpoint the fork rule adds one more per block, two on the non-optimistic path.

Not addressed

  • The underlying gap (bitcoin-sv/teranode#4769) remains: a stored script verdict does not record which script era it was checked under. This PR closes the direct-fork route only, not the reorg route.
  • The non-legacy branch of CheckSubtree (Server.go) still sends transactions to block assembly in every FSM state. Nothing in production calls it; it is reachable only through the internal gRPC API.

Tests

  • TestForkBelowCheckpoint: truth table for the rule.
  • TestValidateBlock_RejectsForkBelowCheckpoint: a side block below a passed checkpoint is rejected before its subtrees are checked and is not stored.
  • TestValidateBlock_RejectsForkBelowCheckpointPassedDuringValidation: the tip passes the checkpoint while the subtrees validate; the block is rejected and not stored.
  • TestSameTxRules: truth table from BDK's probed flag boundaries (regtest P2SH/Genesis/Chronicle/CSV/BIP66/BIP65, mainnet P2SH and UAHF/DAA at fork+1). TestSameTxRules_BIP68FollowsCSVHeight pins the BIP68 term.
  • TestCheckBlockSubtrees_BlockAssemblyFeedFollowsScriptEra: a below-Genesis side block does not feed block assembly (the issue); a same-era competitor whose parent is not the tip does; a block above tip+1 does not.
  • New legacy subtest: a block one below Genesis with tip+1 at Genesis does not feed block assembly; the existing RUNNING subtest (parent not the tip, same era) still feeds.
  • setupTestServer registers no default tip: testify serves the first match, so a default shadowed every test's own expectation. Tests that only need a tip call mockBestBlockHeader.
  • In TestCheckpointValidationCallSites the checkpoint sits above the tip, unreached, so the difficulty gate it tests is still reached.

go test -tags testtxmetacache passes on ./model/, ./services/blockvalidation/, ./services/subtreevalidation/ and ./services/validator/; the first three also with -race. golangci-lint reports nothing new.

…heckpoint and feed block assembly only from tip blocks

A side block is validated at its own height, so below Genesis or Chronicle its
transactions get the older script rules. Block assembly then mines them at
tip+1 without re-checking scripts, and peers holding the stored verdict skip
the re-check too.

- ValidateBlockWithOptions rejects a block whose height is below a checkpoint
  the tip has already reached (bitcoin-sv's bad-fork-prior-to-checkpoint).
  Checkpoints above the tip do not count, so initial sync is unaffected. The
  tip is only read for blocks at or below the highest checkpoint.
- CheckBlockSubtrees and the legacy CheckSubtree path add a block's
  transactions to block assembly only in RUNNING and only when the block's
  parent is the current tip.

Fixes bitcoin-sv/teranode#4940
@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

🤖 Claude Code Review

Status: Complete

Current Review:

  • [Minor] A comment in BlockValidation.go above the checkpoint-hash check still says the fork-below-checkpoint rule is "a separate, broader change". This PR adds that rule, so the comment is stale (inline). Still present at the current head.
  • [Minor] The fork-below-checkpoint test does not assert that AddBlock was skipped, so the CHANGELOG's "Nothing is persisted" claim is not tested. Raised by a reviewer; I agree (thread).

No correctness issues found. The latest commit only merges main.

  • ForkBelowCheckpoint: only counts checkpoints the tip has reached, so initial sync is unaffected. A block exactly at a checkpoint height is still left to the existing hash check. Revalidation skips the peer strike. reconsiderblock is also unaffected: RevalidateBlock only runs on blocks marked invalid, and an invalid block below a checkpoint the tip has passed cannot be an ancestor of the tip, so it really is a fork.
  • sameTxRuleEra / feedsBlockAssembly: ignores unset (negative) heights, works in either argument order, and covers both the CheckBlockSubtrees path and the legacy CheckSubtreeFromBlock path.

Notes (not blocking):

  • Every block at or below the highest checkpoint now makes one extra GetBestBlockHeader call in ValidateBlock. During initial sync that is every block. Each call is cheap, but it is a new per-block RPC. In RUNNING, each subtree-validation call also makes one extra GetBestBlockHeader call. If that call fails, validation fails with a processing error, where before it went ahead.
  • sameTxRuleEra covers the activation heights in chaincfg.Params. It does not cover any flags the script engine derives internally. That gap only matters on networks without checkpoints.

@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Benchmark Comparison Report

Baseline: main (unknown)

Current: PR-2069 (b42aa61)

Summary

  • Regressions: 0
  • Improvements: 0
  • Unchanged: 169
  • Significance level: p < 0.05
All benchmark results (sec/op)
Benchmark Baseline Current Change p-value
_NewBlockFromBytes-4 1.703µ 1.731µ ~ 0.700
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=1024/memory-4 11.88m 11.92m ~ 0.200
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=1024/disk_1-4 12.17m 11.87m ~ 0.100
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=1024/disk_2-4 12.93m 12.93m ~ 1.000
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=16384/memo... 26.57m 26.64m ~ 1.000
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=16384/disk... 29.06m 28.35m ~ 0.700
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=16384/disk... 29.79m 28.13m ~ 0.100
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=131072/mem... 114.8m 116.1m ~ 1.000
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=131072/dis... 193.5m 196.6m ~ 0.700
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=131072/dis... 209.0m 195.3m ~ 0.100
BlockTxMap/txs=16384/nodeindex-4 2.521m 2.508m ~ 1.000
BlockTxMap/txs=16384/swiss-4 14.47m 12.16m ~ 0.200
BlockTxMap/txs=262144/nodeindex-4 18.61m 18.17m ~ 0.200
BlockTxMap/txs=262144/swiss-4 50.44m 49.39m ~ 0.400
BlockTxMap/txs=4194304/nodeindex-4 452.7m 429.8m ~ 0.700
BlockTxMap/txs=4194304/swiss-4 815.8m 879.3m ~ 0.100
TxMapPut_MemoryVsDisk/txs=100000/memory-4 7.475m 7.432m ~ 0.400
TxMapPut_MemoryVsDisk/txs=100000/disk_1-4 6.279m 6.254m ~ 1.000
TxMapPut_MemoryVsDisk/txs=100000/disk_2-4 5.820m 5.988m ~ 0.100
TxMapGetFrozen_MemoryVsDisk/txs=100000/memory/hit-4 4.381m 4.651m ~ 0.700
TxMapGetFrozen_MemoryVsDisk/txs=100000/memory/miss-4 3.437m 3.424m ~ 1.000
TxMapGetFrozen_MemoryVsDisk/txs=100000/disk_1/hit-4 1.905m 2.008m ~ 0.100
TxMapGetFrozen_MemoryVsDisk/txs=100000/disk_1/miss-4 1.865m 1.942m ~ 0.100
TxMapGetFrozen_MemoryVsDisk/txs=100000/disk_2/hit-4 1.899m 2.089m ~ 0.100
TxMapGetFrozen_MemoryVsDisk/txs=100000/disk_2/miss-4 1.902m 2.043m ~ 0.200
ParentSpendsSetIfNotExists_MemoryVsDisk/inputs=200000/dis... 14.76m 14.76m ~ 1.000
ParentSpendsSetIfNotExists_MemoryVsDisk/inputs=200000/dis... 13.99m 13.34m ~ 0.100
InpointDedup/txs=16K-4 5.189m 5.051m ~ 0.700
InpointDedup/txs=128K-4 20.14m 20.02m ~ 1.000
InpointDedup/txs=1M-4 137.4m 156.0m ~ 0.200
InpointDedup_TinyRuns-4 49.79m 49.62m ~ 1.000
MiningCandidate_Stringify_Short-4 237.7n 238.0n ~ 1.000
MiningCandidate_Stringify_Long-4 1.680µ 1.663µ ~ 1.000
MiningSolution_Stringify-4 855.1n 849.7n ~ 0.400
BlockInfo_MarshalJSON-4 2.253µ 2.233µ ~ 1.000
DecodeSubtreeMeta/go-subtree-4 217.3m 209.4m ~ 0.400
DecodeSubtreeMeta/packed-4 60.27m 59.14m ~ 0.700
DecodeSubtreeMeta/go-subtree/parallel-4 105.9m 105.7m ~ 0.700
DecodeSubtreeMeta/packed/parallel-4 40.79m 36.77m ~ 0.700
ValidateSubtreeNodesMatchKey/leaves=1024-4 262.5µ 263.2µ ~ 0.700
ValidateSubtreeNodesMatchKey/leaves=1048576-4 80.94m 81.15m ~ 0.700
NewFromBytes-4 125.8n 125.4n ~ 0.300
AddTxBatchColumnar_Validation-4 2.121µ 2.163µ ~ 0.100
OffsetValidationLoop-4 660.4n 643.9n ~ 0.700
BuildSubtreeMeta/serialized-4 10.75m 10.82m ~ 1.000
BuildSubtreeMeta/decoded-4 20.02m 20.01m ~ 0.700
Mine_EasyDifficulty-4 60.79µ 62.38µ ~ 0.100
Mine_WithAddress-4 7.493µ 7.094µ ~ 0.700
BlockAssembler_AddTx-4 0.02097n 0.02300n ~ 0.700
AddNode-4 12.57 11.95 ~ 0.400
AddNodeWithMap-4 12.05 12.11 ~ 0.200
DirectSubtreeAdd/4_per_subtree-4 80.57n 80.20n ~ 0.700
DirectSubtreeAdd/64_per_subtree-4 36.74n 34.23n ~ 0.700
DirectSubtreeAdd/256_per_subtree-4 32.45n 32.79n ~ 0.400
DirectSubtreeAdd/1024_per_subtree-4 31.34n 31.37n ~ 1.000
DirectSubtreeAdd/2048_per_subtree-4 30.83n 30.68n ~ 1.000
SubtreeProcessorAdd/4_per_subtree-4 250.9n 249.9n ~ 1.000
SubtreeProcessorAdd/64_per_subtree-4 279.9n 275.6n ~ 0.400
SubtreeProcessorAdd/256_per_subtree-4 298.3n 298.0n ~ 1.000
SubtreeProcessorAdd/1024_per_subtree-4 299.9n 299.4n ~ 1.000
SubtreeProcessorAdd/2048_per_subtree-4 294.1n 294.6n ~ 0.700
SubtreeProcessorRotate/4_per_subtree-4 297.7n 297.9n ~ 1.000
SubtreeProcessorRotate/64_per_subtree-4 298.5n 304.9n ~ 0.100
SubtreeProcessorRotate/256_per_subtree-4 302.1n 308.4n ~ 0.100
SubtreeProcessorRotate/1024_per_subtree-4 300.0n 308.3n ~ 0.100
SubtreeNodeAddOnly/4_per_subtree-4 66.33n 66.46n ~ 0.700
SubtreeNodeAddOnly/64_per_subtree-4 40.15n 40.21n ~ 1.000
SubtreeNodeAddOnly/256_per_subtree-4 39.12n 39.34n ~ 0.300
SubtreeNodeAddOnly/1024_per_subtree-4 38.50n 39.31n ~ 0.200
SubtreeCreationOnly/4_per_subtree-4 162.4n 162.2n ~ 1.000
SubtreeCreationOnly/64_per_subtree-4 419.5n 441.9n ~ 1.000
SubtreeCreationOnly/256_per_subtree-4 1.376µ 1.383µ ~ 0.100
SubtreeCreationOnly/1024_per_subtree-4 5.100µ 5.322µ ~ 1.000
SubtreeCreationOnly/2048_per_subtree-4 9.714µ 9.660µ ~ 0.400
SubtreeProcessorOverheadBreakdown/64_per_subtree-4 237.3n 236.1n ~ 0.300
SubtreeProcessorOverheadBreakdown/1024_per_subtree-4 291.1n 292.8n ~ 0.400
ParallelGetAndSetIfNotExists/1k_nodes-4 772.9µ 727.7µ ~ 0.700
ParallelGetAndSetIfNotExists/10k_nodes-4 4.853m 4.898m ~ 0.700
ParallelGetAndSetIfNotExists/50k_nodes-4 7.573m 7.711m ~ 1.000
ParallelGetAndSetIfNotExists/100k_nodes-4 10.20m 10.25m ~ 0.700
SequentialGetAndSetIfNotExists/1k_nodes-4 725.0µ 708.4µ ~ 1.000
SequentialGetAndSetIfNotExists/10k_nodes-4 5.389m 6.038m ~ 0.400
SequentialGetAndSetIfNotExists/50k_nodes-4 19.71m 20.37m ~ 0.100
SequentialGetAndSetIfNotExists/100k_nodes-4 34.28m 37.27m ~ 0.100
ProcessOwnBlockSubtreeNodesParallel/1k_nodes-4 846.2µ 816.1µ ~ 1.000
ProcessOwnBlockSubtreeNodesParallel/10k_nodes-4 7.553m 7.746m ~ 0.700
ProcessOwnBlockSubtreeNodesParallel/100k_nodes-4 13.36m 13.65m ~ 0.100
ProcessOwnBlockSubtreeNodesSequential/1k_nodes-4 773.5µ 899.4µ ~ 0.400
ProcessOwnBlockSubtreeNodesSequential/10k_nodes-4 10.14m 10.05m ~ 0.700
ProcessOwnBlockSubtreeNodesSequential/100k_nodes-4 61.14m 54.73m ~ 0.100
DiskTxMap_SetIfNotExists-4 551.7n 559.6n ~ 1.000
DiskTxMap_SetIfNotExists_Parallel-4 190.1n 191.5n ~ 1.000
DiskTxMap_ExistenceOnly-4 282.2n 307.0n ~ 0.100
Queue-4 169.8n 169.6n ~ 1.000
AtomicPointer-4 4.073n 4.009n ~ 1.000
TxMapSetIfNotExists-4 62.31n 62.50n ~ 0.100
TxMapSetIfNotExistsDuplicate-4 47.76n 47.00n ~ 0.100
ChannelSendReceive-4 596.9n 600.5n ~ 0.100
CalcBlockWork-4 256.6n 262.6n ~ 0.200
CalculateWork-4 343.6n 342.3n ~ 0.700
CheckOldBlockIDs/on-chain-prefetch/1000-4 44.19µ 43.56µ ~ 1.000
CheckOldBlockIDs/in-memory-chain-check/1000-4 1.169m 1.158m ~ 1.000
CheckOldBlockIDs/on-chain-prefetch/10000-4 351.4µ 329.4µ ~ 0.100
CheckOldBlockIDs/in-memory-chain-check/10000-4 1.672m 1.662m ~ 0.100
BuildBlockLocatorString_Helpers/Size_10-4 1.147µ 1.028µ ~ 0.100
BuildBlockLocatorString_Helpers/Size_100-4 10.857µ 9.777µ ~ 0.100
BuildBlockLocatorString_Helpers/Size_1000-4 108.00µ 97.87µ ~ 0.100
CatchupWithHeaderCache-4 106.1m 106.0m ~ 0.700
_BufferPoolAllocation/16KB-4 2.524µ 2.429µ ~ 0.400
_BufferPoolAllocation/32KB-4 5.125µ 6.042µ ~ 0.200
_BufferPoolAllocation/64KB-4 11.05µ 11.57µ ~ 0.100
_BufferPoolAllocation/128KB-4 21.73µ 19.81µ ~ 0.200
_BufferPoolAllocation/512KB-4 93.91µ 85.41µ ~ 0.100
_BufferPoolConcurrent/32KB-4 12.57µ 12.37µ ~ 0.400
_BufferPoolConcurrent/64KB-4 20.35µ 19.92µ ~ 0.400
_BufferPoolConcurrent/512KB-4 101.7µ 107.6µ ~ 0.200
_SubtreeDeserializationWithBufferSizes/16KB-4 359.1µ 356.5µ ~ 1.000
_SubtreeDeserializationWithBufferSizes/32KB-4 358.7µ 347.6µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/64KB-4 352.7µ 352.6µ ~ 1.000
_SubtreeDeserializationWithBufferSizes/128KB-4 353.5µ 364.4µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/512KB-4 362.7µ 379.0µ ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/16KB-4 21.27m 21.88m ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/32KB-4 21.08m 22.28m ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/64KB-4 21.54m 22.30m ~ 0.400
_SubtreeDataDeserializationWithBufferSizes/128KB-4 21.89m 21.94m ~ 1.000
_SubtreeDataDeserializationWithBufferSizes/512KB-4 21.32m 21.78m ~ 0.100
_PooledVsNonPooled/Pooled-4 361.5n 380.2n ~ 0.100
_PooledVsNonPooled/NonPooled-4 4.621µ 5.402µ ~ 0.100
_MemoryFootprint/Current_512KB_32concurrent-4 5.584µ 6.077µ ~ 0.100
_MemoryFootprint/Proposed_32KB_32concurrent-4 6.073µ 6.784µ ~ 0.100
_MemoryFootprint/Alternative_64KB_32concurrent-4 5.716µ 5.830µ ~ 0.400
SubtreeSizes/10k_tx_4_per_subtree-4 1.384m 1.449m ~ 0.400
SubtreeSizes/10k_tx_16_per_subtree-4 325.0µ 328.3µ ~ 0.100
SubtreeSizes/10k_tx_64_per_subtree-4 77.07µ 80.11µ ~ 0.100
SubtreeSizes/10k_tx_256_per_subtree-4 19.52µ 19.70µ ~ 0.400
SubtreeSizes/10k_tx_512_per_subtree-4 9.648µ 10.100µ ~ 0.200
SubtreeSizes/10k_tx_1024_per_subtree-4 5.041µ 4.891µ ~ 0.400
SubtreeSizes/10k_tx_2k_per_subtree-4 2.459µ 2.496µ ~ 0.100
BlockSizeScaling/10k_tx_64_per_subtree-4 81.13µ 83.57µ ~ 0.100
BlockSizeScaling/10k_tx_256_per_subtree-4 19.35µ 19.84µ ~ 0.700
BlockSizeScaling/10k_tx_1024_per_subtree-4 5.224µ 5.341µ ~ 0.400
BlockSizeScaling/50k_tx_64_per_subtree-4 405.3µ 410.5µ ~ 0.400
BlockSizeScaling/50k_tx_256_per_subtree-4 102.1µ 104.6µ ~ 0.700
BlockSizeScaling/50k_tx_1024_per_subtree-4 26.63µ 24.41µ ~ 1.000
SubtreeAllocations/small_subtrees_exists_check-4 176.9µ 180.9µ ~ 0.100
SubtreeAllocations/small_subtrees_data_fetch-4 200.4µ 203.5µ ~ 0.700
SubtreeAllocations/small_subtrees_full_validation-4 385.7µ 389.0µ ~ 0.400
SubtreeAllocations/medium_subtrees_exists_check-4 11.47µ 11.60µ ~ 1.000
SubtreeAllocations/medium_subtrees_data_fetch-4 13.00µ 12.91µ ~ 1.000
SubtreeAllocations/medium_subtrees_full_validation-4 26.45µ 26.22µ ~ 0.700
SubtreeAllocations/large_subtrees_exists_check-4 2.865µ 2.873µ ~ 1.000
SubtreeAllocations/large_subtrees_data_fetch-4 3.438µ 3.320µ ~ 0.700
SubtreeAllocations/large_subtrees_full_validation-4 6.244µ 6.379µ ~ 0.700
_prepareTxsPerLevel-4 313.9m 314.7m ~ 0.400
_prepareTxsPerLevelOrdered-4 3.046m 2.804m ~ 0.100
_prepareTxsPerLevel_Comparison/Original-4 340.9m 347.4m ~ 1.000
_prepareTxsPerLevel_Comparison/Optimized-4 2.754m 2.778m ~ 0.100
StoreBlockForkPath/inMemoryChainCheck=false-4 627.0µ 632.6µ ~ 0.700
StoreBlockForkPath/inMemoryChainCheck=true-4 1.282m 1.321m ~ 0.100
StoreBlock_Sequential/BelowCSVHeight-4 201.8µ 206.2µ ~ 0.100
StoreBlock_Sequential/AboveCSVHeight-4 197.4µ 204.3µ ~ 0.100
CheckSpendAndCreateMultiList_FanIn/parents=80000-4 34.30m 32.07m ~ 0.100
CheckSpendAndCreateMultiList_FanIn/parents=160000-4 80.76m 80.86m ~ 1.000
GetUtxoHashes-4 190.7n 195.1n ~ 0.700
GetUtxoHashes_ManyOutputs-4 26.80µ 27.34µ ~ 0.400
MetaBytes-4 38.95n 39.56n ~ 0.700
_NewMetaDataFromBytes-4 119.2n 120.5n ~ 0.400
_Bytes-4 193.5n 193.9n ~ 1.000
_MetaBytes-4 58.75n 58.72n ~ 1.000

Threshold: >10% with p < 0.05 | Generated: 2026-10-28 11:34 UTC

… parent

Feeding block assembly only from blocks that extend the tip stranded the
txs of a same-height competitor that loses: they are stored unmined but
never reach the template, a resubmission hits ErrTxExists and is not
re-added, and a child arriving later can enter the template without its
parent.

Feed instead when the block's height and tip+1 fall under the same
transaction rules (no BIP65, BIP66, CSV, UAHF, Genesis or Chronicle
activation between them). That still keeps an older-era side block out of
the template, which is what issue 4940 needs.

Also:
- drop the default GetBestBlockHeader mock from setupTestServer; testify
  serves the first match, so it shadowed every test's own expectation.
  Tests that need a tip call mockBestBlockHeader.
- give the checkpoint call-site fixture an unreached checkpoint with a
  hash of its own, and say so in its comment.
- correct the CHANGELOG entry and the mirror comment in the validator.
@icellan icellan changed the title fix(blockvalidation,subtreevalidation): reject forks below a passed checkpoint and feed block assembly only from tip blocks fix(blockvalidation,subtreevalidation): reject forks below a passed checkpoint and keep older-era side blocks out of block assembly Oct 27, 2026
Comment thread services/blockvalidation/BlockValidation.go Outdated

@freemans13 freemans13 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approving. Both rules are correct, and I found nothing blocking. I read head 1b29e02.

Fork rule. ForkBelowCheckpoint only counts checkpoints the tip has reached, so initial sync is unaffected. It cannot catch our own main-chain blocks: an already-stored valid block returns early at the top of ValidateBlockWithOptions, before this check runs. Revalidating an invalid-marked block below a passed checkpoint now fails, which matches bitcoin-sv.

Era rule. sameTxRuleEra covers every activation height that services/validator, model and services/subtreevalidation read: BIP65, BIP66, CSV, UAHF, Genesis and Chronicle. BIP34 affects only the coinbase, and the DAA height affects only difficulty, so leaving both out is right. I also checked that the legacy route passes a real BlockHeight (Client.go:141). Without it the era check would silently turn feeding off.

One non-blocking test gap, inline. The existing bot comment about the stale "separate, broader change" sentence above the checkpoint-hash check still applies at this head.

What I ran. go test -tags testtxmetacache on the new and touched tests in ./model/, ./services/blockvalidation/ and ./services/subtreevalidation/: 123 passed. I did not run -race, the full packages or lint. I did not test the reorg route, which the PR description says it leaves open (bitcoin-sv/teranode#4769).

Comment thread services/blockvalidation/fork_below_checkpoint_test.go

@ordishs ordishs left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The fork rule matches bitcoin-sv's bad-fork-prior-to-checkpoint. Checkpoints above the tip do not count, so initial sync is unaffected. The script-era gate closes the direct-fork route into block assembly. No blocking issues found. Tests pass on ./model/, ./services/blockvalidation/ and ./services/subtreevalidation/.

@oskarszoon oskarszoon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Request changes. The new checkpoint and assembly gates leave reachable cases where a below-checkpoint fork is treated as valid or a transaction enters a template under rules different from those used to validate it.

  • P1 — services/blockvalidation/BlockValidation.go:2116: The checkpoint decision reads the tip before subtree work. Another block can advance the tip past the checkpoint before this block reaches AddBlock, which then stores a below-checkpoint fork. Catchup can also return success for an already-stored valid hash before reaching this check. Enforce the fork rule at a state-coordinated storage boundary and cover the stored-hash path.
  • P1 — services/subtreevalidation/check_block_subtrees.go:422: sameTxRuleEra misses BDK's P2SH and DAA script-flag activations and checks UAHF one block early. Equal activation flags also do not make a transaction final at both candidate heights or parent median times. Compare the mandatory rules and finality for the actual mining context before feeding assembly.
  • P1 — services/subtreevalidation/check_block_subtrees.go:411: The tip is sampled once before subtree loading and validation. If it crosses Genesis or Chronicle before transactions reach assembly, an older-era block can still feed the new-era template. Tie the feed decision to the assembler's current tip era or revalidate queued transactions when that era changes.

@sonarqubecloud

Copy link
Copy Markdown

…rom BDK flags and re-check the checkpoint fork rule before storing

- validator.SameTxRules compares BDK's consensus script flags at both heights
  instead of a list of chaincfg heights. The list missed P2SH, put UAHF and DAA
  one block early, and did not match BDK's STN heights. BIP68 still follows
  ChainCfgParams.CSVHeight.
- feedsBlockAssembly also requires the block not to be above tip+1, so a height
  locktime final at the block is final at tip+1.
- rejectForkBelowCheckpoint runs before subtree work and again just before
  AddBlock, in case the tip passes a checkpoint while the block validates.
- The fork-below-checkpoint test asserts AddBlock is not called; the stale
  "separate, broader change" comment is gone.
@icellan
icellan requested a review from oskarszoon October 28, 2026 13:05
…he non-optimistic store, under its own context

The optimistic path stores the block before any subtree work, right after the
first check, so a second check there covers no window. On the normal path the
re-check runs after the subtree work, so it must not fail on a cancelled parent
context, like the store that follows it.
@icellan

icellan commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

@oskarszoon, point by point. Fixes are in the latest two commits.

P1-1, checkpoint check reads the tip before subtree work (BlockValidation.go). Narrowed. The rule is now rejectForkBelowCheckpoint and it runs twice: before subtree work, and again right before the block is stored on the non-optimistic store/revalidate branch. The optimistic path stores the block before any subtree work, directly after the first check, so there is no window to re-check there. TestValidateBlock_RejectsForkBelowCheckpointPassedDuringValidation has the tip below the checkpoint on the first lookup and past it on the second. It asserts BlockInvalid and that AddBlock is never called. I did not move the rule into the blockchain store's transaction, for two reasons. First, the tip only crosses a checkpoint during initial sync, because checkpoints are historic. Second, a fork that slips through the remaining gap between the re-check and AddBlock cannot pass the exact-hash check at the checkpoint height. So it can never out-work the main chain, and outside RUNNING it never feeds assembly. That is the same dead branch main stores today for every such block.

On the already-stored hash: I don't think that's a gap. A stored valid block below a passed checkpoint was accepted before the tip reached the checkpoint, so it is either a main-chain ancestor or a fork that was valid when it arrived. bitcoin-sv applies bad-fork-prior-to-checkpoint only in ContextualCheckBlockHeader for headers it has not seen. It does not re-reject known blocks. The early return is what stops our own main-chain ancestors from tripping the rule.

P1-2, era rule misses P2SH/DAA and has UAHF off by one (check_block_subtrees.go). Correct. I probed BDK's CalculateFlags across every height on all four networks. The consensus flags change at P2SH (mainnet 173806, testnet 520, regtest/STN 2), at UAHF+1 and DAA+1, and at BDK's STN BIP65/66 heights, which differ from chaincfg. The hand-kept list is gone. validator.SameTxRules now compares BDK's own consensus flags at both heights, plus BIP68 at ChainCfgParams.CSVHeight, because BIP68 is our rule, not BDK's. TestSameTxRules pins the probed boundaries, including mainnet UAHF/DAA at fork+1. The old list fails it.

Finality: the feed now also requires block.Height <= tip+1, so a height locktime final in the block is final at tip+1. Time locktime (side-chain parent MTP above the tip's MTP) and BIP68 against a side-chain-only parent remain. Block assembly holds hash, fee, size and inpoints, not tx bodies, so it cannot re-check either. The same applies to txs re-added on reorg moveBack, on main today, independent of this PR. That belongs with bitcoin-sv/teranode#4769 (stored verdicts don't record the context they were checked under), not in this gate.

P1-3, tip crossing Genesis/Chronicle mid-validation. Not changing this here. Block assembly has no era awareness at all. The same one-block window exists on main for every policy-validated mempool tx in flight when the tip crosses an activation height. Re-validating queued txs when the era changes is a block assembly feature, and it would cover mempool txs too, not just this gate. Both activations are already behind mainnet and testnet, so the window only applies to regtest/STN. I'd file it as a follow-up rather than hold this PR for it.

@oskarszoon oskarszoon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Request changes. The new pre-store checkpoint recheck and BDK flag comparison address parts of the prior review, but the checkpoint rule can still be bypassed by concurrent storage or an already-stored hash, and the assembly feed can still admit transactions invalid for the current mining context.

  • P1 — services/blockvalidation/BlockValidation.go:2824: The second checkpoint check still reads the tip before a separate AddBlock call. A concurrent block can pass the checkpoint between those operations; the optimistic path has only the first check. The existing-valid return at line 1966 also skips the rule for a stored hash reached during catchup. Enforce checkpoint eligibility with the storage decision and on the stored-hash path.
  • P1 — services/validator/same_tx_rules.go:28: Equal script flags and blockHeight <= tip+1 do not establish time-based finality on different branches. A side-block transaction can be final at its parent MTP and non-final for the best tip's next block, yet enter assembly without a finality recheck. Check finality against the assembler's current candidate context.
  • P1 — services/subtreevalidation/check_block_subtrees.go:412: The assembly-feed decision uses one tip snapshot before subtree loading and validation. If the tip crosses Genesis or Chronicle before the transactions are admitted, older-era transactions can still reach the newer-era template. Recheck at assembly admission or discard queued transactions when the tip changes era.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants