Skip to content

fix(blockvalidation): check difficulty of every catch-up header before fetching blocks - #2084

Merged
icellan merged 1 commit into
bsv-blockchain:mainfrom
icellan:claude-issue-4582
Nov 2, 2026
Merged

icellan merged 1 commit into
bsv-blockchain:mainfrom
icellan:claude-issue-4582

Conversation

@icellan

@icellan icellan commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Background

Catch-up's header precheck (step 9.5, validateHeaderChainDifficulty) recomputed the DAA from the fetched headers alone. A header was checked only when its whole 144-block window, plus the median lookback, was inside the fetched run, so the precheck skipped:

  • the first ~147 headers of every fork,
  • every pre-DAA header,
  • chains that never adjust difficulty.

Chain work is only compared for deep forks, so a short fork mined against a target the peer chose could get past it, including a single header on top of our tip. Under the default pessimistic bootstrap mode, step 10 then prewarms subtree data before any block is validated, so the node downloaded block data for headers whose difficulty was never checked. Full-block validation already enforces nBits unconditionally. Catch-up has to do the same before it fetches anything.

Change

  • Every header is checked with the full-block calculator. validateHeaderChainDifficulty now calls blockchain.Difficulty.CalcNextWorkRequiredFrom, the same calculator full-block validation uses. It reads ancestry through a header source: the fetched headers answer for themselves, and the blockchain client answers for the common ancestor and everything below it. Windows that reach below the ancestor are read from the store. Pre-DAA retargets, EDA, testnet minimum difficulty, STN and no-adjustment chains follow the same rules as full-block validation. The hand-written in-memory DAA is gone.
  • It fails closed. A wrong nBits is reported as a malicious peer (invalid_difficulty) before any body is requested. If the expected bits can't be computed, catch-up stops with a ServiceError and the peer isn't blamed. This matches the direct path.
  • One shared header source. The legacy header cache's branchSource moves to services/blockchain/branch_source.go as NewBranchSource, and both callers now use it. netsync's behaviour doesn't change.
  • ComputeTarget is folded back. It was exported only for the old in-memory precheck, so it goes back into Difficulty.computeTarget.
  • Comments and CHANGELOG. The comments in catchup.go and BlockValidation.go that described the skip are updated. There's a CHANGELOG entry under "Not breaking, but worth knowing".

Tests

  • catchup_difficulty_test.go is rewritten against real sqlitememory stores. The oracle is the store-backed CalcNextWorkRequired, which reads ancestry through SQL rather than through the branch source. Cases:
    • an honest run whose windows lie in the store, with irregular spacing and equal-timestamp median ties,
    • one header on our tip with easy bits (the attack this PR closes): rejected, and the expected bits are the oracle's,
    • a three-header fork below the tip: rejected,
    • an unknown anchor, or an unlinked run: refused without blaming the peer,
    • real testnet headers 1–547 from genesis: accepted; a tampered one is rejected,
    • regtest: each header must repeat its parent's bits.
  • New end-to-end test. TestCatchup_HeaderWithWrongDifficultyIsRejectedBeforeAnyBodyFetch runs catchup() against a real store. It asserts that the peer is charged as malicious and is asked for nothing but headers.
  • Mutation check. Reintroducing the old skips (the near-anchor window skip and the no-adjustment early return) fails the five tests that cover them.
  • Existing tests changed:
    • checkpoint_prefix_nbits_test.go used to assert, as a precondition, that the precheck let this bad header through. It now asserts that both the precheck and the body check reject it.
    • The stub-client catch-up fixtures set NoDifficultyAdjustment, because the precheck now reads ancestry the stubs don't have. Only the difficulty calculator reads that flag.
    • The Sybil fixture's "honest" chain dropped from a mainnet 1d00ffff anchor to much easier 207fffff blocks. It's now anchored on the regtest genesis.

Verification

Notes

@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

🤖 Claude Code Review

Status: Complete

Current Review:
No issues found.

The catch-up header precheck now calls Difficulty.CalcNextWorkRequiredFrom for every fetched header. Ancestry comes from the fetched run through blockchain.NewBranchSource, and everything at or below the common ancestor comes from the blockchain client. This is the same calculator GetNextWorkRequired uses on the server side, so the precheck and the full-block check (which is unchanged and still runs on every block) follow the same rules. Those rules cover pre-DAA retargets, EDA, testnet's minimum-difficulty rule, STN and chains that never adjust difficulty. I checked these points:

  • The run is checked against the ancestor again before the walk, and chain work is rebuilt from the ancestor's stored value.
  • The trunk's GetBlockHeaders falls back to the recursive parent_id walk for a stored ancestor that is not on the main chain.
  • Each run gets a fresh calculator, so the last-hash cache never outlives the run's branch.
  • Read failures come back as ServiceError, which catch-up already treats as a local fault rather than charging the peer, as the CHANGELOG says.

The cost matches the CHANGELOG: about 300 extra trunk reads per catch-up for the ~147 headers whose adjustment window reaches below the ancestor. Headers above that window are judged from the in-memory map alone. The CHANGELOG entry sits under [Unreleased], and the updated comments in BlockValidation.go and checkpoint_prefix_nbits_test.go now describe the code correctly.

One reviewer thread is still open. It is non-blocking and asks for a DAA fixture that uses blockchain.NewLocalClient(store) as the trunk, so that fork reads go through a client rather than the store directly. I agree it would be worth adding. I left the thread for the author to answer.

@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Benchmark Comparison Report

Baseline: main (unknown)

Current: PR-2084 (d4579ad)

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.698µ 1.685µ ~ 0.700
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=1024/memory-4 11.88m 11.85m ~ 0.700
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=1024/disk_1-4 12.93m 12.93m ~ 0.400
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=1024/disk_2-4 14.02m 14.01m ~ 0.700
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=16384/memo... 25.51m 25.33m ~ 0.400
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=16384/disk... 33.38m 33.16m ~ 0.200
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=16384/disk... 33.18m 32.91m ~ 0.400
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=131072/mem... 112.6m 116.2m ~ 1.000
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=131072/dis... 228.9m 227.9m ~ 0.700
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=131072/dis... 229.3m 226.2m ~ 0.100
BlockTxMap/txs=16384/nodeindex-4 2.506m 2.502m ~ 0.100
BlockTxMap/txs=16384/swiss-4 16.22m 15.23m ~ 1.000
BlockTxMap/txs=262144/nodeindex-4 18.30m 18.14m ~ 0.400
BlockTxMap/txs=262144/swiss-4 47.93m 47.13m ~ 0.400
BlockTxMap/txs=4194304/nodeindex-4 397.5m 388.1m ~ 0.200
BlockTxMap/txs=4194304/swiss-4 831.1m 781.8m ~ 0.200
TxMapPut_MemoryVsDisk/txs=100000/memory-4 7.613m 7.733m ~ 1.000
TxMapPut_MemoryVsDisk/txs=100000/disk_1-4 12.03m 11.57m ~ 0.100
TxMapPut_MemoryVsDisk/txs=100000/disk_2-4 10.85m 10.36m ~ 0.100
TxMapGetFrozen_MemoryVsDisk/txs=100000/memory/hit-4 4.444m 4.522m ~ 0.700
TxMapGetFrozen_MemoryVsDisk/txs=100000/memory/miss-4 3.481m 3.509m ~ 0.700
TxMapGetFrozen_MemoryVsDisk/txs=100000/disk_1/hit-4 1.898m 1.902m ~ 1.000
TxMapGetFrozen_MemoryVsDisk/txs=100000/disk_1/miss-4 1.881m 1.889m ~ 1.000
TxMapGetFrozen_MemoryVsDisk/txs=100000/disk_2/hit-4 1.935m 1.902m ~ 1.000
TxMapGetFrozen_MemoryVsDisk/txs=100000/disk_2/miss-4 1.930m 1.929m ~ 1.000
ParentSpendsSetIfNotExists_MemoryVsDisk/inputs=200000/dis... 24.57m 24.70m ~ 1.000
ParentSpendsSetIfNotExists_MemoryVsDisk/inputs=200000/dis... 22.62m 22.24m ~ 0.100
InpointDedup/txs=16K-4 4.827m 4.829m ~ 1.000
InpointDedup/txs=128K-4 19.85m 19.45m ~ 1.000
InpointDedup/txs=1M-4 134.8m 134.8m ~ 1.000
InpointDedup_TinyRuns-4 49.49m 50.03m ~ 0.700
MiningCandidate_Stringify_Short-4 230.5n 227.4n ~ 0.300
MiningCandidate_Stringify_Long-4 1.698µ 1.689µ ~ 1.000
MiningSolution_Stringify-4 850.1n 845.6n ~ 0.200
BlockInfo_MarshalJSON-4 2.211µ 2.215µ ~ 0.700
DecodeSubtreeMeta/go-subtree-4 220.3m 208.0m ~ 0.700
DecodeSubtreeMeta/packed-4 57.48m 63.02m ~ 0.700
DecodeSubtreeMeta/go-subtree/parallel-4 104.4m 107.2m ~ 0.100
DecodeSubtreeMeta/packed/parallel-4 37.46m 35.61m ~ 1.000
ValidateSubtreeNodesMatchKey/leaves=1024-4 263.6µ 263.3µ ~ 0.700
ValidateSubtreeNodesMatchKey/leaves=1048576-4 80.68m 81.10m ~ 0.400
NewFromBytes-4 114.3n 114.6n ~ 1.000
AddTxBatchColumnar_Validation-4 2.209µ 2.046µ ~ 0.100
OffsetValidationLoop-4 544.5n 726.0n ~ 0.100
BuildSubtreeMeta/serialized-4 12.40m 11.85m ~ 0.700
BuildSubtreeMeta/decoded-4 25.83m 26.14m ~ 0.700
Mine_EasyDifficulty-4 67.37µ 67.51µ ~ 1.000
Mine_WithAddress-4 7.359µ 7.323µ ~ 0.400
BlockAssembler_AddTx-4 0.02126n 0.01945n ~ 0.400
AddNode-4 11.61 12.23 ~ 0.400
AddNodeWithMap-4 11.34 11.72 ~ 0.100
DirectSubtreeAdd/4_per_subtree-4 83.29n 70.68n ~ 0.200
DirectSubtreeAdd/64_per_subtree-4 34.42n 33.73n ~ 0.400
DirectSubtreeAdd/256_per_subtree-4 33.08n 32.41n ~ 0.400
DirectSubtreeAdd/1024_per_subtree-4 31.45n 31.10n ~ 0.100
DirectSubtreeAdd/2048_per_subtree-4 31.01n 30.19n ~ 0.200
SubtreeProcessorAdd/4_per_subtree-4 259.9n 258.0n ~ 0.400
SubtreeProcessorAdd/64_per_subtree-4 290.5n 289.5n ~ 1.000
SubtreeProcessorAdd/256_per_subtree-4 311.4n 305.0n ~ 1.000
SubtreeProcessorAdd/1024_per_subtree-4 300.9n 303.5n ~ 0.800
SubtreeProcessorAdd/2048_per_subtree-4 308.6n 302.5n ~ 0.200
SubtreeProcessorRotate/4_per_subtree-4 311.2n 309.4n ~ 0.700
SubtreeProcessorRotate/64_per_subtree-4 306.6n 319.6n ~ 0.100
SubtreeProcessorRotate/256_per_subtree-4 306.3n 311.7n ~ 0.200
SubtreeProcessorRotate/1024_per_subtree-4 303.1n 309.7n ~ 0.200
SubtreeNodeAddOnly/4_per_subtree-4 65.98n 68.48n ~ 0.700
SubtreeNodeAddOnly/64_per_subtree-4 40.35n 41.58n ~ 0.400
SubtreeNodeAddOnly/256_per_subtree-4 39.50n 40.20n ~ 0.100
SubtreeNodeAddOnly/1024_per_subtree-4 39.05n 40.05n ~ 0.100
SubtreeCreationOnly/4_per_subtree-4 164.1n 166.9n ~ 1.000
SubtreeCreationOnly/64_per_subtree-4 437.4n 429.2n ~ 1.000
SubtreeCreationOnly/256_per_subtree-4 1.450µ 1.497µ ~ 0.100
SubtreeCreationOnly/1024_per_subtree-4 4.894µ 5.118µ ~ 1.000
SubtreeCreationOnly/2048_per_subtree-4 11.07µ 11.03µ ~ 1.000
SubtreeProcessorOverheadBreakdown/64_per_subtree-4 244.6n 241.9n ~ 0.400
SubtreeProcessorOverheadBreakdown/1024_per_subtree-4 299.0n 296.1n ~ 0.400
ParallelGetAndSetIfNotExists/1k_nodes-4 799.2µ 795.6µ ~ 0.700
ParallelGetAndSetIfNotExists/10k_nodes-4 5.630m 5.345m ~ 0.400
ParallelGetAndSetIfNotExists/50k_nodes-4 8.411m 8.009m ~ 0.700
ParallelGetAndSetIfNotExists/100k_nodes-4 11.05m 11.41m ~ 0.400
SequentialGetAndSetIfNotExists/1k_nodes-4 730.6µ 759.8µ ~ 0.400
SequentialGetAndSetIfNotExists/10k_nodes-4 7.991m 7.506m ~ 0.400
SequentialGetAndSetIfNotExists/50k_nodes-4 26.93m 29.56m ~ 0.400
SequentialGetAndSetIfNotExists/100k_nodes-4 41.68m 49.43m ~ 0.400
ProcessOwnBlockSubtreeNodesParallel/1k_nodes-4 820.6µ 817.8µ ~ 1.000
ProcessOwnBlockSubtreeNodesParallel/10k_nodes-4 8.320m 8.407m ~ 0.700
ProcessOwnBlockSubtreeNodesParallel/100k_nodes-4 14.10m 14.21m ~ 1.000
ProcessOwnBlockSubtreeNodesSequential/1k_nodes-4 831.6µ 874.0µ ~ 1.000
ProcessOwnBlockSubtreeNodesSequential/10k_nodes-4 12.46m 11.95m ~ 0.700
ProcessOwnBlockSubtreeNodesSequential/100k_nodes-4 85.98m 72.70m ~ 0.100
DiskTxMap_SetIfNotExists-4 501.3n 502.6n ~ 0.700
DiskTxMap_SetIfNotExists_Parallel-4 202.9n 195.1n ~ 0.700
DiskTxMap_ExistenceOnly-4 237.9n 242.5n ~ 0.700
Queue-4 132.8n 131.8n ~ 0.200
AtomicPointer-4 2.781n 2.774n ~ 0.400
TxMapSetIfNotExists-4 39.77n 38.81n ~ 0.100
TxMapSetIfNotExistsDuplicate-4 33.64n 33.14n ~ 0.100
ChannelSendReceive-4 421.2n 425.2n ~ 0.100
CalcBlockWork-4 364.4n 364.0n ~ 0.700
CalculateWork-4 480.0n 485.1n ~ 0.400
CheckOldBlockIDs/on-chain-prefetch/1000-4 64.70µ 55.54µ ~ 0.100
CheckOldBlockIDs/in-memory-chain-check/1000-4 1.484m 1.547m ~ 0.200
CheckOldBlockIDs/on-chain-prefetch/10000-4 438.0µ 533.1µ ~ 0.700
CheckOldBlockIDs/in-memory-chain-check/10000-4 2.137m 2.129m ~ 0.700
BuildBlockLocatorString_Helpers/Size_10-4 1.342µ 1.337µ ~ 0.200
BuildBlockLocatorString_Helpers/Size_100-4 12.76µ 12.75µ ~ 0.400
BuildBlockLocatorString_Helpers/Size_1000-4 126.5µ 125.8µ ~ 0.200
CatchupWithHeaderCache-4 107.2m 107.0m ~ 0.700
_BufferPoolAllocation/16KB-4 4.002µ 3.378µ ~ 0.700
_BufferPoolAllocation/32KB-4 7.230µ 7.510µ ~ 0.400
_BufferPoolAllocation/64KB-4 13.55µ 16.66µ ~ 0.100
_BufferPoolAllocation/128KB-4 26.35µ 30.29µ ~ 0.100
_BufferPoolAllocation/512KB-4 105.1µ 106.0µ ~ 1.000
_BufferPoolConcurrent/32KB-4 16.21µ 15.95µ ~ 0.100
_BufferPoolConcurrent/64KB-4 25.80µ 25.49µ ~ 0.100
_BufferPoolConcurrent/512KB-4 137.8µ 133.4µ ~ 0.400
_SubtreeDeserializationWithBufferSizes/16KB-4 510.9µ 519.0µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/32KB-4 507.5µ 503.1µ ~ 0.400
_SubtreeDeserializationWithBufferSizes/64KB-4 505.2µ 511.8µ ~ 1.000
_SubtreeDeserializationWithBufferSizes/128KB-4 516.1µ 510.9µ ~ 1.000
_SubtreeDeserializationWithBufferSizes/512KB-4 525.1µ 507.9µ ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/16KB-4 27.45m 27.40m ~ 1.000
_SubtreeDataDeserializationWithBufferSizes/32KB-4 27.54m 27.17m ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/64KB-4 27.41m 27.18m ~ 0.200
_SubtreeDataDeserializationWithBufferSizes/128KB-4 27.19m 27.20m ~ 0.700
_SubtreeDataDeserializationWithBufferSizes/512KB-4 27.06m 26.78m ~ 0.100
_PooledVsNonPooled/Pooled-4 637.2n 646.0n ~ 0.100
_PooledVsNonPooled/NonPooled-4 6.146µ 7.015µ ~ 0.100
_MemoryFootprint/Current_512KB_32concurrent-4 5.967µ 6.070µ ~ 0.400
_MemoryFootprint/Proposed_32KB_32concurrent-4 7.398µ 8.394µ ~ 0.100
_MemoryFootprint/Alternative_64KB_32concurrent-4 6.821µ 8.405µ ~ 0.100
SubtreeSizes/10k_tx_4_per_subtree-4 991.2µ 1003.6µ ~ 1.000
SubtreeSizes/10k_tx_16_per_subtree-4 239.5µ 239.4µ ~ 1.000
SubtreeSizes/10k_tx_64_per_subtree-4 56.44µ 56.42µ ~ 1.000
SubtreeSizes/10k_tx_256_per_subtree-4 14.26µ 14.24µ ~ 1.000
SubtreeSizes/10k_tx_512_per_subtree-4 7.241µ 7.216µ ~ 0.700
SubtreeSizes/10k_tx_1024_per_subtree-4 3.606µ 3.583µ ~ 1.000
SubtreeSizes/10k_tx_2k_per_subtree-4 1.817µ 1.811µ ~ 0.700
BlockSizeScaling/10k_tx_64_per_subtree-4 59.70µ 58.68µ ~ 1.000
BlockSizeScaling/10k_tx_256_per_subtree-4 14.02µ 14.34µ ~ 0.400
BlockSizeScaling/10k_tx_1024_per_subtree-4 3.800µ 3.788µ ~ 1.000
BlockSizeScaling/50k_tx_64_per_subtree-4 301.6µ 303.1µ ~ 1.000
BlockSizeScaling/50k_tx_256_per_subtree-4 72.49µ 73.35µ ~ 0.700
BlockSizeScaling/50k_tx_1024_per_subtree-4 20.16µ 20.05µ ~ 1.000
SubtreeAllocations/small_subtrees_exists_check-4 134.9µ 135.5µ ~ 1.000
SubtreeAllocations/small_subtrees_data_fetch-4 146.5µ 147.4µ ~ 1.000
SubtreeAllocations/small_subtrees_full_validation-4 285.5µ 288.0µ ~ 0.700
SubtreeAllocations/medium_subtrees_exists_check-4 8.533µ 8.577µ ~ 0.700
SubtreeAllocations/medium_subtrees_data_fetch-4 9.273µ 9.311µ ~ 0.700
SubtreeAllocations/medium_subtrees_full_validation-4 17.76µ 18.36µ ~ 0.700
SubtreeAllocations/large_subtrees_exists_check-4 2.174µ 2.184µ ~ 0.300
SubtreeAllocations/large_subtrees_data_fetch-4 2.351µ 2.332µ ~ 0.200
SubtreeAllocations/large_subtrees_full_validation-4 4.593µ 4.565µ ~ 0.400
_prepareTxsPerLevel-4 447.1m 515.4m ~ 0.400
_prepareTxsPerLevelOrdered-4 5.771m 6.465m ~ 0.400
_prepareTxsPerLevel_Comparison/Original-4 468.8m 508.2m ~ 0.200
_prepareTxsPerLevel_Comparison/Optimized-4 5.917m 6.570m ~ 0.400
StoreBlockForkPath/inMemoryChainCheck=false-4 421.7µ 423.9µ ~ 0.200
StoreBlockForkPath/inMemoryChainCheck=true-4 1.039m 1.042m ~ 0.400
StoreBlock_Sequential/BelowCSVHeight-4 133.5µ 135.5µ ~ 0.100
StoreBlock_Sequential/AboveCSVHeight-4 134.2µ 135.4µ ~ 0.700
CheckSpendAndCreateMultiList_FanIn/parents=80000-4 50.46m 48.15m ~ 0.400
CheckSpendAndCreateMultiList_FanIn/parents=160000-4 120.0m 124.3m ~ 0.700
GetUtxoHashes-4 294.9n 297.8n ~ 1.000
GetUtxoHashes_ManyOutputs-4 42.69µ 41.95µ ~ 0.100
MetaBytes-4 69.70n 68.34n ~ 0.100
_NewMetaDataFromBytes-4 226.9n 225.4n ~ 0.300
_Bytes-4 345.1n 338.0n ~ 0.700
_MetaBytes-4 107.9n 107.6n ~ 0.700

Threshold: >10% with p < 0.05 | Generated: 2026-11-02 13:40 UTC

@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. The precheck now runs every catch-up header through the same calculator full-block validation uses, so the first ~147 headers of a fork, pre-DAA headers and no-adjustment chains are no longer waved through to the subtree prewarm. I read the diff on 0875f49, the moved branchSource, and CalcNextWorkRequiredFrom end to end, including the historical and testnet min-difficulty paths.

What I checked:

  • Parity with full-block validation. Both call sites in BlockValidation.go (lines 2259 and 3670) pass the candidate's own timestamp as currentBlockTime, and the precheck passes int64(h.Timestamp). So the testnet 2x-spacing rule fires on the same input in both places.
  • The fresh Difficulty per run. Its lastBlockHash and lastTestnetTarget caches start empty, so nothing carries over between peers. blockassembly_difficultyCache defaults to true, so the testnet walk-back reuses lastTestnetTarget within a run rather than re-walking up to 2016 headers for each one.
  • Error routing. A wrong nBits is malicious; a failed anchor read or calculator error is a ServiceError and the peer is not charged. The unlinked-run ProcessingError cannot be reached after step 8, and is the safe answer if it ever is.
  • CHANGELOG entry sits under ## [Unreleased].

I ran go test -race -tags testtxmetacache ./services/blockvalidation/ ./services/legacy/netsync/ ./services/blockchain/ on 0875f49 locally: 3735 passed across the three packages.

One non-blocking note inline about which trunk the tests read through. I did not run the mutation check you describe, and I did not exercise the change on a live node.

if tSettings.ChainCfgParams.NoDifficultyAdjustment {
// Returns a NetworkPeerMaliciousError if any header's nBits differ from the requirement, a
// ServiceError if the requirement cannot be computed, or nil if every header is correct.
// Failing closed matters: anything this lets through goes on to have its blocks fetched.

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.

Non-blocking. Production passes u.blockchainClient as trunk, but every DAA case in catchup_difficulty_test.go passes the sqlitememory store directly. The end-to-end test (TestCatchup_HeaderWithWrongDifficultyIsRejectedBeforeAnyBodyFetch) does go through NewLocalClient, but it runs on regtest, where CalcNextWorkRequiredFrom returns the parent's bits before reading any ancestry. So no test reads a fork's GetSuitableBlock / GetHashOfAncestorBlock / GetBlockHeaders through a client.

The risk is small: legacy netsync already hands the blockchain client to the same branch source as its trunk (services/legacy/netsync/manager.go:4508), so that path runs live today. If you want it pinned here too, running one of the DAA fixtures with blockchain.NewLocalClient(store) as the trunk would close it.

…e fetching blocks

Catch-up's header precheck (step 9.5) recomputed the DAA from the fetched
headers alone, so it skipped every header whose 144-block window reached
below the common ancestor - the first ~147 of any fork - as well as pre-DAA
headers and chains with no difficulty adjustment. A short fork mined against
a target the peer chose, including one header on top of our tip, passed into
step 10, where the subtree prewarm downloads block data before any block is
validated.

The precheck now uses blockchain.Difficulty.CalcNextWorkRequiredFrom, the
calculator full-block validation uses, over a header source in which the
fetched headers answer for themselves and the blockchain client answers for
the ancestor and everything below it. Every header is judged, and the check
fails closed: a wrong nBits is a malicious peer, and a requirement that cannot
be computed stops catch-up without blaming the peer.

The header source is the legacy header cache's branchSource, moved to
services/blockchain as NewBranchSource so both callers share it. ComputeTarget
was exported only for the old in-memory precheck and is folded back into
Difficulty.computeTarget.

Stub-client catch-up fixtures now declare NoDifficultyAdjustment, since the
precheck reads ancestry the stub does not have, and the Sybil fixture is
anchored on a 207fffff chain so its honest chain repeats its anchor's bits.
@sonarqubecloud

sonarqubecloud Bot commented Nov 2, 2026

Copy link
Copy Markdown

@icellan
icellan merged commit 585aa46 into bsv-blockchain:main Nov 2, 2026
38 checks passed
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