Skip to content

fix(legacy): gate quickValidation on checkpoints, not FSM state#847

Merged
oskarszoon merged 3 commits into
bsv-blockchain:mainfrom
oskarszoon:fix/legacy-quickvalidation-checkpoint-gate
May 12, 2026
Merged

fix(legacy): gate quickValidation on checkpoints, not FSM state#847
oskarszoon merged 3 commits into
bsv-blockchain:mainfrom
oskarszoon:fix/legacy-quickvalidation-checkpoint-gate

Conversation

@oskarszoon

Copy link
Copy Markdown
Contributor

Summary

  • prepareSubtrees in legacy netsync only enabled quickValidationMode when the FSM was in LEGACYSYNCING. CATCHINGBLOCKS (catchup-driven sync from block 0, or after a fork) fell back to the slow path: full subtree re-validation through subtreeValidation, no pre-allocated block ID, and per-UTXO SetMinedMulti via setTxMinedStatus.
  • The trust property that makes the fast path safe is checkpoint coverage + PoW, not the FSM state that happens to be active. The local chain extending through known checkpoints, combined with the upstream HasMetTargetDifficulty check, is what makes the block canonical.
  • Replace the FSM-state lookup with a check against the highest hard-coded checkpoint, reusing the helper added in fix(blockchain): reject FSM RUN below highest network checkpoint #844 (blockchain.HighestCheckpointHeight). Blocks at/below the highest checkpoint take the fast path regardless of FSM state; blocks above it always take the regular validation path.
  • Drops the IsFSMCurrentState RPC from the per-block hot path. Updates the buildAddBlockOpts doc block in blockvalidation to describe the broader scope.

Why this is safe

The fast path skips two things: subtree re-validation through subtreeValidation, and setTxMinedStatus' post-storage double-spend cross-check. Both are redundant when:

  1. block.Valid() has already run pre-storage double-spend detection via checkParentExistsOnChain before AddBlock.
  2. The block height is at/below a checkpoint owned by go-chaincfg, so chain linkage from genesis is provably canonical.

This is the same invariant #844 uses to gate the FSM-RUN transition. The checkpoint list is bumped by updating go-chaincfg; the gate moves forward with it.

Trade-offs / follow-ups

  • mainnet's highest checkpoint is currently 938000 (tip is ~948000), so the top ~10k blocks won't get the fast path under any FSM state. Acceptable — they need normal validation anyway, and bumping go-chaincfg's checkpoint list will close the gap.

Test plan

  • go build ./...
  • go vet ./services/legacy/netsync/... ./services/blockvalidation/...
  • go test -race -short ./services/legacy/netsync/ — 49 passed including new TestSyncManager_quickValidationAllowed (6 sub-cases: nil chainParams, regtest, mainnet height 0, equal-to-highest, one-above-highest)
  • go test -race -short ./services/blockvalidation/
  • Manual: legacy sync from block 0 on testnet — verify fast path engages under both LEGACYSYNCING and CATCHINGBLOCKS (look for [validateTransactionsLegacyMode] created utxos log line)

quickValidationMode in prepareSubtrees was gated on FSMStateLEGACYSYNCING,
so CATCHINGBLOCKS (catchup-driven re-syncs from block 0 or after a fork)
fell back to the slow path: full subtree re-validation via subtreeValidation,
no pre-allocated block ID, and per-UTXO SetMinedMulti through setTxMinedStatus.

The trust property that justifies the fast path is checkpoint coverage plus
PoW, not the FSM state that happens to be active. Replace the FSM lookup
with a check against the highest hard-coded checkpoint, reusing the helper
added in bsv-blockchain#844 (blockchain.HighestCheckpointHeight). Blocks at/below the
highest checkpoint take the fast path regardless of FSM state; blocks above
it always take the regular validation path.

Drops the IsFSMCurrentState RPC from the per-block hot path. Updates the
buildAddBlockOpts doc block in blockvalidation to reflect the broader scope.
@oskarszoon oskarszoon requested review from freemans13 and ordishs May 12, 2026 10:59
@github-actions

github-actions Bot commented May 12, 2026

Copy link
Copy Markdown
Contributor

🤖 Claude Code Review

Status: Complete


Summary

This PR correctly replaces FSM-state-based gating with checkpoint-based gating for the quick validation path. The implementation is well-tested and documented. The changes align with the security model established in PR #844.

Findings: No issues found.

The PR demonstrates careful engineering:

  • Removes the IsFSMCurrentState RPC call from the per-block hot path
  • Replaces FSM-state check with checkpoint height comparison using blockchain.HighestCheckpointHeight
  • Maintains safety invariants: checkpoint coverage + PoW validation = canonical chain
  • Comprehensive test coverage including edge cases (nil params, regtest, boundary conditions)
  • Documentation accurately reflects the implementation changes

The implementation correctly handles edge cases (nil chainParams, no-checkpoint networks) and the security rationale is sound.

…sed scope

Aligns docs/topics/services/legacy.md with the code change in the parent
commit — quick validation now engages for any block at/below the highest
hard-coded checkpoint, regardless of whether the FSM is in LEGACYSYNCING
or CATCHINGBLOCKS.
@github-actions

github-actions Bot commented May 12, 2026

Copy link
Copy Markdown
Contributor

Benchmark Comparison Report

Baseline: main (unknown)

Current: PR-847 (6080f8d)

Summary

  • Regressions: 0
  • Improvements: 0
  • Unchanged: 142
  • Significance level: p < 0.05
All benchmark results (sec/op)
Benchmark Baseline Current Change p-value
_NewBlockFromBytes-4 1.210µ 1.225µ ~ 0.400
SplitSyncedParentMap_SetIfNotExists/256_buckets-4 55.02n 55.04n ~ 1.000
SplitSyncedParentMap_SetIfNotExists/16_buckets-4 55.00n 54.94n ~ 0.700
SplitSyncedParentMap_SetIfNotExists/1_bucket-4 54.97n 55.06n ~ 0.100
SplitSyncedParentMap_ConcurrentSetIfNotExists/256_buckets... 25.46n 26.14n ~ 0.400
SplitSyncedParentMap_ConcurrentSetIfNotExists/16_buckets_... 43.45n 44.25n ~ 0.400
SplitSyncedParentMap_ConcurrentSetIfNotExists/1_bucket_pa... 97.20n 98.70n ~ 0.400
MiningCandidate_Stringify_Short-4 175.5n 168.6n ~ 0.100
MiningCandidate_Stringify_Long-4 1.249µ 1.256µ ~ 0.700
MiningSolution_Stringify-4 664.3n 659.1n ~ 0.200
BlockInfo_MarshalJSON-4 1.337µ 1.357µ ~ 0.100
NewFromBytes-4 128.0n 129.4n ~ 0.100
Mine_EasyDifficulty-4 60.48µ 61.18µ ~ 0.100
Mine_WithAddress-4 6.976µ 6.873µ ~ 0.700
DirectSubtreeAdd/4_per_subtree-4 58.81n 61.99n ~ 0.100
DirectSubtreeAdd/64_per_subtree-4 28.33n 28.72n ~ 0.700
DirectSubtreeAdd/256_per_subtree-4 27.41n 26.94n ~ 0.100
DirectSubtreeAdd/1024_per_subtree-4 26.29n 25.97n ~ 0.100
DirectSubtreeAdd/2048_per_subtree-4 25.89n 25.64n ~ 0.100
SubtreeProcessorAdd/4_per_subtree-4 279.3n 279.6n ~ 1.000
SubtreeProcessorAdd/64_per_subtree-4 272.0n 271.9n ~ 1.000
SubtreeProcessorAdd/256_per_subtree-4 274.0n 275.1n ~ 0.400
SubtreeProcessorAdd/1024_per_subtree-4 267.1n 268.2n ~ 0.400
SubtreeProcessorAdd/2048_per_subtree-4 267.5n 268.6n ~ 0.700
SubtreeProcessorRotate/4_per_subtree-4 272.8n 273.8n ~ 0.100
SubtreeProcessorRotate/64_per_subtree-4 271.3n 270.4n ~ 0.700
SubtreeProcessorRotate/256_per_subtree-4 272.4n 271.7n ~ 0.600
SubtreeProcessorRotate/1024_per_subtree-4 270.8n 270.8n ~ 0.800
SubtreeNodeAddOnly/4_per_subtree-4 54.03n 53.85n ~ 1.000
SubtreeNodeAddOnly/64_per_subtree-4 34.20n 34.25n ~ 0.700
SubtreeNodeAddOnly/256_per_subtree-4 33.45n 33.36n ~ 0.400
SubtreeNodeAddOnly/1024_per_subtree-4 32.66n 32.61n ~ 0.500
SubtreeCreationOnly/4_per_subtree-4 111.9n 112.1n ~ 0.800
SubtreeCreationOnly/64_per_subtree-4 402.1n 395.4n ~ 0.200
SubtreeCreationOnly/256_per_subtree-4 1.335µ 1.422µ ~ 0.100
SubtreeCreationOnly/1024_per_subtree-4 4.398µ 4.585µ ~ 0.100
SubtreeCreationOnly/2048_per_subtree-4 8.019µ 8.089µ ~ 0.100
SubtreeProcessorOverheadBreakdown/64_per_subtree-4 268.6n 276.7n ~ 0.100
SubtreeProcessorOverheadBreakdown/1024_per_subtree-4 269.0n 275.2n ~ 0.100
ParallelGetAndSetIfNotExists/1k_nodes-4 587.3µ 817.8µ ~ 0.100
ParallelGetAndSetIfNotExists/10k_nodes-4 1.349m 1.317m ~ 0.700
ParallelGetAndSetIfNotExists/50k_nodes-4 6.749m 6.755m ~ 1.000
ParallelGetAndSetIfNotExists/100k_nodes-4 13.62m 13.54m ~ 0.400
SequentialGetAndSetIfNotExists/1k_nodes-4 672.4µ 654.2µ ~ 0.100
SequentialGetAndSetIfNotExists/10k_nodes-4 2.933m 2.917m ~ 1.000
SequentialGetAndSetIfNotExists/50k_nodes-4 10.69m 10.56m ~ 0.700
SequentialGetAndSetIfNotExists/100k_nodes-4 20.01m 20.30m ~ 0.200
ProcessOwnBlockSubtreeNodesParallel/1k_nodes-4 634.7µ 649.0µ ~ 0.100
ProcessOwnBlockSubtreeNodesParallel/10k_nodes-4 4.175m 4.191m ~ 0.700
ProcessOwnBlockSubtreeNodesParallel/100k_nodes-4 16.52m 16.66m ~ 0.100
ProcessOwnBlockSubtreeNodesSequential/1k_nodes-4 697.3µ 695.9µ ~ 1.000
ProcessOwnBlockSubtreeNodesSequential/10k_nodes-4 5.844m 5.759m ~ 0.100
ProcessOwnBlockSubtreeNodesSequential/100k_nodes-4 37.53m 38.11m ~ 0.200
BlockAssembler_AddTx-4 0.02873n 0.02657n ~ 0.400
AddNode-4 10.49 11.39 ~ 0.200
AddNodeWithMap-4 11.39 10.87 ~ 0.700
DiskTxMap_SetIfNotExists-4 4.026µ 4.022µ ~ 0.700
DiskTxMap_SetIfNotExists_Parallel-4 3.915µ 3.718µ ~ 0.100
DiskTxMap_ExistenceOnly-4 315.6n 304.9n ~ 0.100
Queue-4 201.2n 201.3n ~ 1.000
AtomicPointer-4 8.125n 8.140n ~ 0.700
ReorgOptimizations/DedupFilterPipeline/Old/10K-4 779.8µ 764.6µ ~ 0.200
ReorgOptimizations/DedupFilterPipeline/New/10K-4 796.3µ 758.8µ ~ 0.100
ReorgOptimizations/AllMarkFalse/Old/10K-4 117.3µ 117.8µ ~ 0.700
ReorgOptimizations/AllMarkFalse/New/10K-4 58.32µ 58.49µ ~ 0.200
ReorgOptimizations/HashSlicePool/Old/10K-4 65.38µ 69.95µ ~ 0.700
ReorgOptimizations/HashSlicePool/New/10K-4 11.80µ 11.79µ ~ 0.700
ReorgOptimizations/NodeFlags/Old/10K-4 5.255µ 5.414µ ~ 0.700
ReorgOptimizations/NodeFlags/New/10K-4 1.795µ 1.827µ ~ 0.100
ReorgOptimizations/DedupFilterPipeline/Old/100K-4 10.33m 11.14m ~ 0.100
ReorgOptimizations/DedupFilterPipeline/New/100K-4 10.56m 11.19m ~ 0.100
ReorgOptimizations/AllMarkFalse/Old/100K-4 1.132m 1.154m ~ 1.000
ReorgOptimizations/AllMarkFalse/New/100K-4 727.1µ 734.3µ ~ 0.700
ReorgOptimizations/HashSlicePool/Old/100K-4 602.0µ 599.0µ ~ 1.000
ReorgOptimizations/HashSlicePool/New/100K-4 310.2µ 319.1µ ~ 0.400
ReorgOptimizations/NodeFlags/Old/100K-4 54.16µ 58.39µ ~ 0.200
ReorgOptimizations/NodeFlags/New/100K-4 19.22µ 19.41µ ~ 0.200
TxMapSetIfNotExists-4 50.22n 51.64n ~ 0.100
TxMapSetIfNotExistsDuplicate-4 43.15n 43.18n ~ 0.400
ChannelSendReceive-4 692.9n 686.2n ~ 0.100
CalcBlockWork-4 513.8n 502.7n ~ 0.100
CalculateWork-4 686.7n 693.8n ~ 0.100
BuildBlockLocatorString_Helpers/Size_10-4 1.312µ 1.299µ ~ 0.400
BuildBlockLocatorString_Helpers/Size_100-4 12.59µ 12.50µ ~ 0.200
BuildBlockLocatorString_Helpers/Size_1000-4 132.6µ 151.4µ ~ 0.700
CatchupWithHeaderCache-4 104.5m 104.3m ~ 0.200
_BufferPoolAllocation/16KB-4 3.401µ 3.411µ ~ 1.000
_BufferPoolAllocation/32KB-4 8.394µ 7.694µ ~ 1.000
_BufferPoolAllocation/64KB-4 14.65µ 17.83µ ~ 0.100
_BufferPoolAllocation/128KB-4 28.79µ 33.17µ ~ 0.100
_BufferPoolAllocation/512KB-4 104.8µ 114.5µ ~ 0.100
_BufferPoolConcurrent/32KB-4 19.06µ 19.55µ ~ 0.200
_BufferPoolConcurrent/64KB-4 31.03µ 28.19µ ~ 0.100
_BufferPoolConcurrent/512KB-4 149.4µ 145.4µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/16KB-4 629.9µ 616.3µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/32KB-4 628.0µ 634.7µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/64KB-4 623.2µ 636.9µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/128KB-4 620.5µ 634.6µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/512KB-4 629.2µ 642.1µ ~ 0.200
_SubtreeDataDeserializationWithBufferSizes/16KB-4 36.07m 35.92m ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/32KB-4 36.00m 36.03m ~ 1.000
_SubtreeDataDeserializationWithBufferSizes/64KB-4 36.32m 35.72m ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/128KB-4 35.73m 35.75m ~ 1.000
_SubtreeDataDeserializationWithBufferSizes/512KB-4 35.78m 35.57m ~ 1.000
_PooledVsNonPooled/Pooled-4 835.3n 829.5n ~ 0.100
_PooledVsNonPooled/NonPooled-4 7.272µ 6.749µ ~ 0.100
_MemoryFootprint/Current_512KB_32concurrent-4 7.632µ 7.728µ ~ 0.100
_MemoryFootprint/Proposed_32KB_32concurrent-4 11.373µ 9.603µ ~ 0.100
_MemoryFootprint/Alternative_64KB_32concurrent-4 10.477µ 9.195µ ~ 0.100
_prepareTxsPerLevel-4 396.8m 421.1m ~ 0.700
_prepareTxsPerLevelOrdered-4 3.774m 3.861m ~ 0.100
_prepareTxsPerLevel_Comparison/Original-4 406.2m 419.5m ~ 0.100
_prepareTxsPerLevel_Comparison/Optimized-4 3.833m 4.021m ~ 0.100
SubtreeSizes/10k_tx_4_per_subtree-4 1.266m 1.248m ~ 0.100
SubtreeSizes/10k_tx_16_per_subtree-4 302.1µ 301.4µ ~ 0.700
SubtreeSizes/10k_tx_64_per_subtree-4 70.47µ 71.53µ ~ 0.200
SubtreeSizes/10k_tx_256_per_subtree-4 17.80µ 17.70µ ~ 0.300
SubtreeSizes/10k_tx_512_per_subtree-4 8.823µ 8.866µ ~ 0.100
SubtreeSizes/10k_tx_1024_per_subtree-4 4.373µ 4.418µ ~ 0.100
SubtreeSizes/10k_tx_2k_per_subtree-4 2.181µ 2.172µ ~ 1.000
BlockSizeScaling/10k_tx_64_per_subtree-4 69.46µ 69.49µ ~ 1.000
BlockSizeScaling/10k_tx_256_per_subtree-4 17.35µ 17.57µ ~ 0.200
BlockSizeScaling/10k_tx_1024_per_subtree-4 4.302µ 4.313µ ~ 1.000
BlockSizeScaling/50k_tx_64_per_subtree-4 369.3µ 372.2µ ~ 0.400
BlockSizeScaling/50k_tx_256_per_subtree-4 86.80µ 87.12µ ~ 1.000
BlockSizeScaling/50k_tx_1024_per_subtree-4 21.63µ 21.69µ ~ 0.400
SubtreeAllocations/small_subtrees_exists_check-4 151.0µ 148.2µ ~ 0.700
SubtreeAllocations/small_subtrees_data_fetch-4 157.4µ 155.8µ ~ 0.400
SubtreeAllocations/small_subtrees_full_validation-4 308.2µ 311.2µ ~ 1.000
SubtreeAllocations/medium_subtrees_exists_check-4 8.811µ 8.766µ ~ 0.400
SubtreeAllocations/medium_subtrees_data_fetch-4 9.305µ 9.350µ ~ 0.700
SubtreeAllocations/medium_subtrees_full_validation-4 17.79µ 17.42µ ~ 0.700
SubtreeAllocations/large_subtrees_exists_check-4 2.081µ 2.107µ ~ 0.700
SubtreeAllocations/large_subtrees_data_fetch-4 2.222µ 2.226µ ~ 1.000
SubtreeAllocations/large_subtrees_full_validation-4 4.316µ 4.344µ ~ 0.700
StoreBlock_Sequential/BelowCSVHeight-4 338.3µ 316.3µ ~ 0.200
StoreBlock_Sequential/AboveCSVHeight-4 334.4µ 326.6µ ~ 1.000
GetUtxoHashes-4 256.7n 258.7n ~ 0.100
GetUtxoHashes_ManyOutputs-4 42.86µ 42.83µ ~ 0.400
_NewMetaDataFromBytes-4 237.6n 240.0n ~ 0.700
_Bytes-4 615.5n 614.3n ~ 1.000
_MetaBytes-4 555.3n 558.7n ~ 0.700

Threshold: >10% with p < 0.05 | Generated: 2026-05-12 12:10 UTC

@oskarszoon oskarszoon requested a review from icellan May 12, 2026 11:46
Comment thread services/legacy/netsync/handle_block.go Outdated
handle_block.go does not import services/legacy/blockchain, so there is no
name collision to disambiguate. The alias was carried over from manager.go
out of habit; reverting to the package's natural name keeps the import
simpler. Manager.go still needs the alias because it imports both packages.
@sonarqubecloud

Copy link
Copy Markdown

@oskarszoon oskarszoon enabled auto-merge (squash) May 12, 2026 12:19
@oskarszoon oskarszoon self-assigned this May 12, 2026

@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.

LGTM — clean swap of the FSM-state gate for a checkpoint-coverage gate. Trust model is sound (PoW + checkpoint linkage), pre-storage double-spend check still runs via checkParentExistsOnChain, and removing the per-block IsFSMCurrentState RPC is a nice bonus.

One nit (non-blocking):

handle_block_test.go:790mainnetHighest is derived as chaincfg.MainNetParams.Checkpoints[len-1].Height, which assumes the slice is sorted ascending. The helper under test (HighestCheckpointHeight) deliberately does not make that assumption. If go-chaincfg ever ships an unordered list, the "one above highest" sub-case would silently become "not above highest" and pass spuriously.

Suggest mirroring the helper:

mainnetHighest := blockchain.HighestCheckpointHeight(chaincfg.MainNetParams.Checkpoints)

Happy to land as-is if you'd rather not churn.

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.

3 participants