Skip to content

fix(subtreevalidation): decouple peer subtree fetch cap from local assembly policy#911

Merged
ordishs merged 12 commits into
bsv-blockchain:mainfrom
ordishs:oli
May 21, 2026
Merged

fix(subtreevalidation): decouple peer subtree fetch cap from local assembly policy#911
ordishs merged 12 commits into
bsv-blockchain:mainfrom
ordishs:oli

Conversation

@ordishs

@ordishs ordishs commented May 20, 2026

Copy link
Copy Markdown
Collaborator

Closes #905.

Problem

PR #772 (fix(subtreevalidation): bound HTTP body reads when fetching subtree data) introduced a hard regression for any node whose local maximum_merkle_items_per_subtree is smaller than the network's real subtree size. Catchup aborts on every peer and the node stops advancing.

The bound was derived from local assembly policy:

maxSubtreeBytes := int64(u.settings.BlockAssembly.MaximumMerkleItemsPerSubtree) * int64(chainhash.HashSize)

But MaximumMerkleItemsPerSubtree controls what this node assembles, not what peers produce. On the docker profile (settings.conf:1089):

maximum_merkle_items_per_subtree.docker = 32768

→ cap = 32768 × 32 = 1,048,576 bytes (1 MiB). Real teratestnet subtrees exceed 1 MiB → bounded reader rejects every peer response → catchup aborts every cycle.

Observed on bsva-ovh-teranode-ttn-eu-3 running v0.15.1-beta-2 syncing teratestnet: node tip stuck at height 174, network tip ~19k.

Fix

Add SubtreeValidation.MaxIncomingSubtreeBytes (default 128 MiB) as a receive-side DoS cap that is independent of local assembly policy. Use it at the three peer-fetch sites:

  • services/blockvalidation/get_blocks.go — catchup subtree fetch
  • services/subtreevalidation/SubtreeValidation.gogetSubtreeTxHashes
  • services/subtreevalidation/check_block_subtrees.go — peer fallback in CheckBlockSubtrees

128 MiB gives 4× headroom over the default mainnet subtree size (1M items × 32 bytes = 32 MiB) while preserving the OOM protection PR #772 added.

Test plan

  • Updated all three *_OversizedBody tests to drive the cap via MaxIncomingSubtreeBytes.
  • Added regression tests (TestFetchSubtreeFromPeer_LocalAssemblyPolicyIgnored, TestGetSubtreeTxHashes_LocalAssemblyPolicyIgnored) that simulate the docker quickstart profile (small local assembly cap + generous receive cap) and verify peer responses larger than the local assembly policy are accepted.
  • go build ./... clean.
  • go vet ./settings/... ./services/blockvalidation/... ./services/subtreevalidation/... ./util/... clean.
  • go test -race -tags testtxmetacache ./services/blockvalidation/ ./services/subtreevalidation/ ./settings/ ./util/ passes (full package suites).

ordishs added 10 commits May 13, 2026 15:38
…ckchain#900)

PR bsv-blockchain#198 replaced the O(1) baseIdx = subIdx*subtreeSize formula with
a prefix-sum loop summing Subtree.Size() across all previous subtrees.
Subtree.Size() takes the subtree's RWMutex internally, so under the
concurrent dedup workers the per-block cost scaled as O(N^2) atomic
ops on shared cache lines.

On a 450k-subtree block in production this pinned every available
core on lock contention with effectively zero useful dedup work being
done — blockvalidation sat at 1161% CPU for 6+ minutes.

The justification comment was incorrect: validateAndSetTransactionCount
enforces "all subtrees must be the same size as the first tree, except
the last one". Because the loop only sums subtrees strictly before
subIdx, every summed subtree is full-size, so subIdx*subtreeSize gives
the exact same baseIdx the loop would produce.

Adds a regression test that exercises the dedup path with 2000
subtrees (last one smaller) and asserts both index correctness at
scale and a generous wall-time budget any reintroduced O(N^2) would
trivially blow past.
The O(1) path completes the checkDuplicateTransactions call in well under
100 ms; 30s left no real fence against a re-introduced O(N^2) regression.
2s sits ~20x above measured headroom, comfortably accommodates slow CI
runners, and still trips immediately on the production-style scaling
issue from bsv-blockchain#900.
…sembly policy

PR bsv-blockchain#772 bounded incoming peer subtree responses by
BlockAssembly.MaximumMerkleItemsPerSubtree * chainhash.HashSize. That value
controls what *this node* assembles, not what peers send, so any node whose
local cap is smaller than the network's actual subtree size rejects every
peer response and catchup stalls.

Repro: docker quickstart on teratestnet (maximum_merkle_items_per_subtree
= 32768 -> 1 MiB cap). Real teratestnet subtrees exceed 1 MiB, so the
bounded reader returns ErrExternal for every peer, fetchAndStore aborts,
and the node never advances past the height where the bound was first
exceeded.

Fix: add SubtreeValidation.MaxIncomingSubtreeBytes (default 128 MiB) as a
receive-side DoS cap independent of assembly policy, and use it at the
three peer-fetch sites (services/blockvalidation/get_blocks.go,
services/subtreevalidation/SubtreeValidation.go,
services/subtreevalidation/check_block_subtrees.go).

128 MiB gives 4x headroom over the default mainnet subtree size (1M items
* 32 bytes = 32 MiB) while preserving the OOM protection PR bsv-blockchain#772 added.

Closes bsv-blockchain#905
@github-actions

github-actions Bot commented May 20, 2026

Copy link
Copy Markdown
Contributor

🤖 Claude Code Review

Status: Complete


Analysis Summary:

This PR correctly fixes issue #905 by decoupling the peer subtree fetch cap from local assembly policy. The fix is well-designed and properly tested.

Verified:

✅ All three peer-fetch sites updated consistently
✅ New config setting properly typed (int64) and documented
✅ Comprehensive regression tests added for all three paths
✅ Type compatibility verified (int64 → int64)
✅ Previous critical issue (leaf count validation) has been fixed

History:

  • ✅ Fixed: Previous inline comment flagged incomplete leaf-count validation in CheckBlockSubtrees - now correctly uses maxIncomingLeaves derived from MaxIncomingSubtreeBytes (line 238)

No issues found. The implementation follows the epistemic honesty and verification principles from AGENTS.md.

@github-actions

github-actions Bot commented May 20, 2026

Copy link
Copy Markdown
Contributor

Benchmark Comparison Report

Baseline: main (unknown)

Current: PR-911 (34dd0fa)

Summary

  • Regressions: 0
  • Improvements: 0
  • Unchanged: 144
  • Significance level: p < 0.05
All benchmark results (sec/op)
Benchmark Baseline Current Change p-value
_NewBlockFromBytes-4 1.764µ 1.833µ ~ 0.100
SplitSyncedParentMap_SetIfNotExists/256_buckets-4 59.39n 59.73n ~ 0.700
SplitSyncedParentMap_SetIfNotExists/16_buckets-4 59.41n 59.42n ~ 1.000
SplitSyncedParentMap_SetIfNotExists/1_bucket-4 59.35n 59.31n ~ 1.000
SplitSyncedParentMap_ConcurrentSetIfNotExists/256_buckets... 38.18n 37.65n ~ 0.700
SplitSyncedParentMap_ConcurrentSetIfNotExists/16_buckets_... 65.20n 63.38n ~ 0.400
SplitSyncedParentMap_ConcurrentSetIfNotExists/1_bucket_pa... 173.0n 182.5n ~ 0.700
MiningCandidate_Stringify_Short-4 261.4n 261.8n ~ 0.400
MiningCandidate_Stringify_Long-4 1.783µ 1.807µ ~ 0.100
MiningSolution_Stringify-4 951.8n 946.3n ~ 0.700
BlockInfo_MarshalJSON-4 1.795µ 1.790µ ~ 0.400
NewFromBytes-4 126.9n 127.7n ~ 0.200
AddTxBatchColumnar_Validation-4 2.471µ 2.488µ ~ 0.100
OffsetValidationLoop-4 641.9n 637.1n ~ 0.700
Mine_EasyDifficulty-4 60.78µ 60.45µ ~ 0.100
Mine_WithAddress-4 6.830µ 6.796µ ~ 1.000
BlockAssembler_AddTx-4 0.03162n 0.02553n ~ 0.200
AddNode-4 10.73 10.69 ~ 0.700
AddNodeWithMap-4 11.83 11.56 ~ 0.200
DirectSubtreeAdd/4_per_subtree-4 57.13n 58.68n ~ 0.700
DirectSubtreeAdd/64_per_subtree-4 28.46n 30.90n ~ 0.100
DirectSubtreeAdd/256_per_subtree-4 27.29n 29.71n ~ 0.100
DirectSubtreeAdd/1024_per_subtree-4 26.14n 28.51n ~ 0.100
DirectSubtreeAdd/2048_per_subtree-4 25.89n 28.08n ~ 0.100
SubtreeProcessorAdd/4_per_subtree-4 288.8n 301.8n ~ 0.700
SubtreeProcessorAdd/64_per_subtree-4 286.5n 286.1n ~ 1.000
SubtreeProcessorAdd/256_per_subtree-4 289.2n 286.5n ~ 1.000
SubtreeProcessorAdd/1024_per_subtree-4 282.9n 280.8n ~ 0.400
SubtreeProcessorAdd/2048_per_subtree-4 292.2n 274.0n ~ 0.200
SubtreeProcessorRotate/4_per_subtree-4 291.3n 281.5n ~ 0.100
SubtreeProcessorRotate/64_per_subtree-4 316.6n 280.2n ~ 0.100
SubtreeProcessorRotate/256_per_subtree-4 297.8n 277.8n ~ 0.100
SubtreeProcessorRotate/1024_per_subtree-4 291.0n 283.6n ~ 0.200
SubtreeNodeAddOnly/4_per_subtree-4 55.05n 54.80n ~ 0.400
SubtreeNodeAddOnly/64_per_subtree-4 34.34n 34.25n ~ 0.500
SubtreeNodeAddOnly/256_per_subtree-4 33.35n 33.47n ~ 0.300
SubtreeNodeAddOnly/1024_per_subtree-4 32.78n 32.62n ~ 0.200
SubtreeCreationOnly/4_per_subtree-4 115.2n 114.3n ~ 0.500
SubtreeCreationOnly/64_per_subtree-4 404.6n 409.5n ~ 0.700
SubtreeCreationOnly/256_per_subtree-4 1.367µ 1.366µ ~ 1.000
SubtreeCreationOnly/1024_per_subtree-4 4.406µ 4.427µ ~ 0.700
SubtreeCreationOnly/2048_per_subtree-4 8.107µ 8.303µ ~ 0.100
SubtreeProcessorOverheadBreakdown/64_per_subtree-4 286.7n 298.4n ~ 0.100
SubtreeProcessorOverheadBreakdown/1024_per_subtree-4 284.0n 277.4n ~ 0.100
ParallelGetAndSetIfNotExists/1k_nodes-4 2.243m 2.248m ~ 0.700
ParallelGetAndSetIfNotExists/10k_nodes-4 5.523m 5.622m ~ 0.100
ParallelGetAndSetIfNotExists/50k_nodes-4 8.043m 8.114m ~ 0.700
ParallelGetAndSetIfNotExists/100k_nodes-4 10.96m 11.23m ~ 1.000
SequentialGetAndSetIfNotExists/1k_nodes-4 2.009m 1.967m ~ 0.100
SequentialGetAndSetIfNotExists/10k_nodes-4 5.979m 5.305m ~ 0.100
SequentialGetAndSetIfNotExists/50k_nodes-4 19.64m 14.55m ~ 0.100
SequentialGetAndSetIfNotExists/100k_nodes-4 29.28m 26.28m ~ 0.100
ProcessOwnBlockSubtreeNodesParallel/1k_nodes-4 2.346m 2.289m ~ 0.100
ProcessOwnBlockSubtreeNodesParallel/10k_nodes-4 8.403m 8.501m ~ 0.100
ProcessOwnBlockSubtreeNodesParallel/100k_nodes-4 15.66m 14.12m ~ 0.100
ProcessOwnBlockSubtreeNodesSequential/1k_nodes-4 2.048m 2.018m ~ 0.200
ProcessOwnBlockSubtreeNodesSequential/10k_nodes-4 10.380m 8.270m ~ 0.100
ProcessOwnBlockSubtreeNodesSequential/100k_nodes-4 56.75m 44.84m ~ 0.100
DiskTxMap_SetIfNotExists-4 3.469µ 3.580µ ~ 0.700
DiskTxMap_SetIfNotExists_Parallel-4 3.341µ 3.511µ ~ 0.700
DiskTxMap_ExistenceOnly-4 305.6n 308.4n ~ 0.400
Queue-4 184.4n 184.7n ~ 1.000
AtomicPointer-4 3.629n 3.247n ~ 0.100
ReorgOptimizations/DedupFilterPipeline/Old/10K-4 807.0µ 798.7µ ~ 1.000
ReorgOptimizations/DedupFilterPipeline/New/10K-4 760.6µ 764.5µ ~ 0.100
ReorgOptimizations/AllMarkFalse/Old/10K-4 102.2µ 101.6µ ~ 0.200
ReorgOptimizations/AllMarkFalse/New/10K-4 64.71µ 64.13µ ~ 0.400
ReorgOptimizations/HashSlicePool/Old/10K-4 56.09µ 50.95µ ~ 0.100
ReorgOptimizations/HashSlicePool/New/10K-4 11.17µ 11.24µ ~ 0.100
ReorgOptimizations/NodeFlags/Old/10K-4 5.300µ 4.487µ ~ 0.100
ReorgOptimizations/NodeFlags/New/10K-4 2.296µ 1.517µ ~ 0.100
ReorgOptimizations/DedupFilterPipeline/Old/100K-4 9.205m 9.141m ~ 0.700
ReorgOptimizations/DedupFilterPipeline/New/100K-4 9.618m 9.271m ~ 0.100
ReorgOptimizations/AllMarkFalse/Old/100K-4 1.056m 1.060m ~ 0.100
ReorgOptimizations/AllMarkFalse/New/100K-4 705.5µ 705.6µ ~ 1.000
ReorgOptimizations/HashSlicePool/Old/100K-4 598.6µ 486.2µ ~ 0.100
ReorgOptimizations/HashSlicePool/New/100K-4 204.3µ 194.2µ ~ 0.100
ReorgOptimizations/NodeFlags/Old/100K-4 42.21µ 49.11µ ~ 0.100
ReorgOptimizations/NodeFlags/New/100K-4 14.71µ 16.64µ ~ 0.100
TxMapSetIfNotExists-4 49.33n 58.43n ~ 0.100
TxMapSetIfNotExistsDuplicate-4 41.36n 41.36n ~ 1.000
ChannelSendReceive-4 570.2n 604.2n ~ 0.100
CalcBlockWork-4 498.6n 533.3n ~ 1.000
CalculateWork-4 690.6n 689.2n ~ 1.000
BuildBlockLocatorString_Helpers/Size_10-4 1.348µ 1.342µ ~ 0.400
BuildBlockLocatorString_Helpers/Size_100-4 12.87µ 15.81µ ~ 0.700
BuildBlockLocatorString_Helpers/Size_1000-4 126.5µ 127.1µ ~ 0.100
CatchupWithHeaderCache-4 104.4m 104.5m ~ 0.700
_BufferPoolAllocation/16KB-4 4.001µ 4.020µ ~ 0.400
_BufferPoolAllocation/32KB-4 7.923µ 9.582µ ~ 0.100
_BufferPoolAllocation/64KB-4 15.57µ 20.83µ ~ 0.100
_BufferPoolAllocation/128KB-4 30.25µ 33.59µ ~ 0.700
_BufferPoolAllocation/512KB-4 127.8µ 110.5µ ~ 0.200
_BufferPoolConcurrent/32KB-4 20.02µ 19.39µ ~ 0.200
_BufferPoolConcurrent/64KB-4 29.64µ 29.95µ ~ 0.100
_BufferPoolConcurrent/512KB-4 160.1µ 153.8µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/16KB-4 686.6µ 650.3µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/32KB-4 688.8µ 637.3µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/64KB-4 686.9µ 622.7µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/128KB-4 684.2µ 611.7µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/512KB-4 686.7µ 628.2µ ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/16KB-4 37.33m 37.32m ~ 1.000
_SubtreeDataDeserializationWithBufferSizes/32KB-4 37.04m 37.16m ~ 0.200
_SubtreeDataDeserializationWithBufferSizes/64KB-4 37.22m 37.19m ~ 1.000
_SubtreeDataDeserializationWithBufferSizes/128KB-4 37.45m 36.95m ~ 0.400
_SubtreeDataDeserializationWithBufferSizes/512KB-4 36.96m 37.07m ~ 0.700
_PooledVsNonPooled/Pooled-4 742.0n 747.4n ~ 0.400
_PooledVsNonPooled/NonPooled-4 8.070µ 8.063µ ~ 1.000
_MemoryFootprint/Current_512KB_32concurrent-4 7.170µ 7.196µ ~ 0.700
_MemoryFootprint/Proposed_32KB_32concurrent-4 9.787µ 10.294µ ~ 0.100
_MemoryFootprint/Alternative_64KB_32concurrent-4 9.603µ 10.358µ ~ 0.100
_prepareTxsPerLevel-4 410.4m 413.0m ~ 0.400
_prepareTxsPerLevelOrdered-4 4.237m 3.593m ~ 0.200
_prepareTxsPerLevel_Comparison/Original-4 410.8m 405.3m ~ 0.700
_prepareTxsPerLevel_Comparison/Optimized-4 3.952m 3.631m ~ 0.100
SubtreeSizes/10k_tx_4_per_subtree-4 1.301m 1.259m ~ 0.100
SubtreeSizes/10k_tx_16_per_subtree-4 301.0µ 301.0µ ~ 1.000
SubtreeSizes/10k_tx_64_per_subtree-4 71.26µ 71.68µ ~ 0.400
SubtreeSizes/10k_tx_256_per_subtree-4 17.93µ 17.84µ ~ 0.700
SubtreeSizes/10k_tx_512_per_subtree-4 8.923µ 8.788µ ~ 0.100
SubtreeSizes/10k_tx_1024_per_subtree-4 4.376µ 4.377µ ~ 1.000
SubtreeSizes/10k_tx_2k_per_subtree-4 2.175µ 2.173µ ~ 0.700
BlockSizeScaling/10k_tx_64_per_subtree-4 69.60µ 69.28µ ~ 0.700
BlockSizeScaling/10k_tx_256_per_subtree-4 17.50µ 17.59µ ~ 1.000
BlockSizeScaling/10k_tx_1024_per_subtree-4 4.369µ 4.414µ ~ 1.000
BlockSizeScaling/50k_tx_64_per_subtree-4 376.3µ 371.9µ ~ 0.400
BlockSizeScaling/50k_tx_256_per_subtree-4 87.89µ 87.37µ ~ 0.400
BlockSizeScaling/50k_tx_1024_per_subtree-4 21.82µ 21.87µ ~ 1.000
SubtreeAllocations/small_subtrees_exists_check-4 150.4µ 151.4µ ~ 1.000
SubtreeAllocations/small_subtrees_data_fetch-4 160.3µ 160.5µ ~ 1.000
SubtreeAllocations/small_subtrees_full_validation-4 310.0µ 309.1µ ~ 0.700
SubtreeAllocations/medium_subtrees_exists_check-4 8.914µ 9.011µ ~ 0.700
SubtreeAllocations/medium_subtrees_data_fetch-4 9.380µ 9.419µ ~ 0.700
SubtreeAllocations/medium_subtrees_full_validation-4 17.48µ 17.43µ ~ 0.700
SubtreeAllocations/large_subtrees_exists_check-4 2.096µ 2.085µ ~ 0.500
SubtreeAllocations/large_subtrees_data_fetch-4 2.251µ 2.222µ ~ 0.200
SubtreeAllocations/large_subtrees_full_validation-4 4.325µ 4.377µ ~ 0.700
StoreBlock_Sequential/BelowCSVHeight-4 338.7µ 331.4µ ~ 0.100
StoreBlock_Sequential/AboveCSVHeight-4 337.0µ 333.1µ ~ 0.700
GetUtxoHashes-4 262.7n 259.6n ~ 1.000
GetUtxoHashes_ManyOutputs-4 41.98µ 42.14µ ~ 0.700
_NewMetaDataFromBytes-4 229.8n 228.8n ~ 1.000
_Bytes-4 398.3n 404.5n ~ 0.100
_MetaBytes-4 340.3n 340.2n ~ 1.000

Threshold: >10% with p < 0.05 | Generated: 2026-05-20 15:19 UTC

@ordishs ordishs requested review from icellan and oskarszoon May 20, 2026 09:55
// malicious peer can't OOM us by streaming oversized responses. This must be
// independent of local BlockAssembly.MaximumMerkleItemsPerSubtree, which only
// controls what *this node* assembles; peers may legitimately produce larger subtrees.
maxSubtreeBytes := int64(u.settings.SubtreeValidation.MaxIncomingSubtreeBytes)

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.

[Critical] Incomplete fix downstream

While this line correctly uses MaxIncomingSubtreeBytes for the HTTP body read (fixing the immediate DoS issue), the subsequent validation at line 233 still checks leaf count against BlockAssembly.MaximumMerkleItemsPerSubtree:

if err := validateSubtreeLeafCount(subtreeHash, leafCount, u.settings.BlockAssembly.MaximumMerkleItemsPerSubtree)

This means a docker profile node will accept the 2 MiB response here but immediately reject it 15 lines later because 65536 items exceeds the 32768 assembly cap. The fix is incomplete for the CheckBlockSubtrees path.

The other two sites (fetchSubtreeFromPeer and getSubtreeTxHashes) correctly omit the post-read leaf count validation.

…leaf-count gate

The peer-fetch fallback at check_block_subtrees.go bounds the HTTP body by
MaxIncomingSubtreeBytes but immediately re-validates the derived leaf count
against BlockAssembly.MaximumMerkleItemsPerSubtree. On the docker quickstart
profile (assembly cap 32k * 32 bytes = 1 MiB) a 2 MiB peer subtree passes the
new 128 MiB body cap and then fails the leaf-count gate with "exceeds policy
max" — the same stuck-at-height regression bsv-blockchain#905 set out to fix, one
statement later.

Derive the leaf-count bound from MaxIncomingSubtreeBytes / chainhash.HashSize.
The bounded HTTP read already enforces this implicitly; the explicit check
remains as a guard before subtreepkg.NewIncompleteTreeByLeafCount allocates
against the count.

Also:
- Change MaxIncomingSubtreeBytes from int to int64; it's a byte count and
  DoHTTPRequestBounded already takes int64. Drop the redundant int64(...)
  casts at the three call sites.
- Add TestCheckBlockSubtrees_LocalAssemblyPolicyIgnored mirroring the
  existing regression tests for fetchSubtreeFromPeer and getSubtreeTxHashes.
@sonarqubecloud

Copy link
Copy Markdown

@ordishs ordishs merged commit 857a1b1 into bsv-blockchain:main May 21, 2026
25 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.

catchup broken on docker/test profiles after PR #772: subtree fetch bound by local policy rejects peer responses

3 participants