Skip to content

fix(legacy/netsync): cap orphan tx pool size to prevent memory DoS#776

Merged
ordishs merged 3 commits into
bsv-blockchain:mainfrom
ordishs:security/4575-orphan-pool-cap
May 26, 2026
Merged

fix(legacy/netsync): cap orphan tx pool size to prevent memory DoS#776
ordishs merged 3 commits into
bsv-blockchain:mainfrom
ordishs:security/4575-orphan-pool-cap

Conversation

@ordishs

@ordishs ordishs commented Apr 28, 2026

Copy link
Copy Markdown
Collaborator

Closes #4575.

Summary

The orphan transaction pool (SyncManager.orphanTxs) was an expiringmap configured with only a TTL — no max-entry cap. A peer flooding unique orphan txs faster than the TTL eviction rate would cause unbounded memory growth.

Fix

Two layers:

  1. util/expiringmap: new WithMaxSize(n int) option enforces a hard cap with oldest-first eviction on insert. Existing WithEvictionChannel / WithEvictionFunction still fire on cap eviction. Default (no WithMaxSize) preserves existing unbounded behaviour for backwards compatibility.
  2. services/legacy/netsync/manager.go: orphan pool wired with cap from new LegacySettings.MaxOrphanTxs (default 100, matches Bitcoin Core's historical default).

Test plan

  • expiringmap.WithMaxSize unit tests: cap enforced, update doesn't trigger eviction, eviction-channel fires, eviction-function fires (and can veto), cap=0 unbounded, no-cap default.
  • Orphan-pool regression test: insert 10 unique orphan txs into a pool wired exactly as SyncManager wires it with cap=5, assert Len() == 5.
  • Default-settings test: tSettings.Legacy.MaxOrphanTxs > 0 so the cap is enforced out-of-the-box.
  • go test -race ./util/expiringmap/... and go test -race ./services/legacy/netsync/... pass.
  • Other existing callers of expiringmap (blockvalidation, s3, subtreevalidation, blockpersister/utxoset/model) are unaffected — they pass -race and continue to default to unbounded.

@github-actions

github-actions Bot commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

🤖 Claude Code Review

Status: Complete


Current Review:

This PR adds a hard cap to the orphan transaction pool to prevent memory exhaustion DoS attacks. The implementation is well-structured with comprehensive tests.

Minor Observation:

One design choice worth noting: When an existing key is updated via Set(), the addedAt timestamp is reset to the current time (line 113 in expiringmap.go). This means eviction is based on "last modified" time rather than "original insertion" time.

Impact: A repeatedly updated orphan transaction will never be evicted based on age, only via TTL expiry. For the orphan pool use case, this may be acceptable since orphan transactions are typically write-once. However, if other parts of the codebase use expiringmap with frequent updates, those entries would become "sticky" and harder to evict under capacity pressure.

The existing test TestExpiringMap_WithMaxSize_UpdateExistingKey verifies that updates don't trigger unnecessary evictions, but doesn't assert whether the timestamp reset behavior is intentional.

No action required unless the intended semantics are "evict by insertion time" rather than "evict by modification time."

@github-actions

github-actions Bot commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

Benchmark Comparison Report

Baseline: main (unknown)

Current: PR-776 (1579b62)

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.760µ 1.749µ ~ 0.400
SplitSyncedParentMap_SetIfNotExists/256_buckets-4 61.84n 61.70n ~ 0.400
SplitSyncedParentMap_SetIfNotExists/16_buckets-4 61.74n 61.97n ~ 1.000
SplitSyncedParentMap_SetIfNotExists/1_bucket-4 61.68n 61.80n ~ 0.700
SplitSyncedParentMap_ConcurrentSetIfNotExists/256_buckets... 29.54n 31.18n ~ 0.200
SplitSyncedParentMap_ConcurrentSetIfNotExists/16_buckets_... 50.54n 50.73n ~ 0.400
SplitSyncedParentMap_ConcurrentSetIfNotExists/1_bucket_pa... 108.7n 118.0n ~ 0.100
MiningCandidate_Stringify_Short-4 261.0n 277.8n ~ 0.100
MiningCandidate_Stringify_Long-4 1.880µ 1.931µ ~ 0.100
MiningSolution_Stringify-4 975.6n 1016.0n ~ 0.100
BlockInfo_MarshalJSON-4 1.803µ 1.832µ ~ 0.100
NewFromBytes-4 129.1n 141.3n ~ 0.700
AddTxBatchColumnar_Validation-4 2.481µ 2.486µ ~ 1.000
OffsetValidationLoop-4 633.7n 638.1n ~ 0.100
Mine_EasyDifficulty-4 66.95µ 67.20µ ~ 0.200
Mine_WithAddress-4 6.940µ 6.927µ ~ 1.000
BlockAssembler_AddTx-4 0.02481n 0.02687n ~ 0.100
AddNode-4 10.86 10.96 ~ 0.700
AddNodeWithMap-4 11.11 11.77 ~ 0.100
DirectSubtreeAdd/4_per_subtree-4 59.04n 59.80n ~ 0.400
DirectSubtreeAdd/64_per_subtree-4 29.54n 29.61n ~ 1.000
DirectSubtreeAdd/256_per_subtree-4 28.47n 28.23n ~ 0.200
DirectSubtreeAdd/1024_per_subtree-4 26.82n 26.82n ~ 1.000
DirectSubtreeAdd/2048_per_subtree-4 26.40n 26.28n ~ 0.400
SubtreeProcessorAdd/4_per_subtree-4 294.3n 338.2n ~ 0.100
SubtreeProcessorAdd/64_per_subtree-4 294.0n 340.5n ~ 0.100
SubtreeProcessorAdd/256_per_subtree-4 297.9n 393.7n ~ 0.100
SubtreeProcessorAdd/1024_per_subtree-4 291.6n 319.5n ~ 0.100
SubtreeProcessorAdd/2048_per_subtree-4 315.8n 302.1n ~ 0.700
SubtreeProcessorRotate/4_per_subtree-4 292.0n 315.1n ~ 0.400
SubtreeProcessorRotate/64_per_subtree-4 292.6n 306.2n ~ 0.100
SubtreeProcessorRotate/256_per_subtree-4 296.0n 319.9n ~ 0.400
SubtreeProcessorRotate/1024_per_subtree-4 283.1n 304.8n ~ 0.200
SubtreeNodeAddOnly/4_per_subtree-4 56.18n 55.81n ~ 0.700
SubtreeNodeAddOnly/64_per_subtree-4 36.33n 36.35n ~ 1.000
SubtreeNodeAddOnly/256_per_subtree-4 35.38n 35.40n ~ 1.000
SubtreeNodeAddOnly/1024_per_subtree-4 34.68n 34.80n ~ 0.100
SubtreeCreationOnly/4_per_subtree-4 116.6n 114.3n ~ 0.100
SubtreeCreationOnly/64_per_subtree-4 382.9n 374.8n ~ 0.100
SubtreeCreationOnly/256_per_subtree-4 1.333µ 1.336µ ~ 1.000
SubtreeCreationOnly/1024_per_subtree-4 4.216µ 4.172µ ~ 1.000
SubtreeCreationOnly/2048_per_subtree-4 7.614µ 7.732µ ~ 0.200
SubtreeProcessorOverheadBreakdown/64_per_subtree-4 337.1n 289.5n ~ 0.700
SubtreeProcessorOverheadBreakdown/1024_per_subtree-4 315.8n 294.6n ~ 0.100
ParallelGetAndSetIfNotExists/1k_nodes-4 2.071m 2.052m ~ 0.400
ParallelGetAndSetIfNotExists/10k_nodes-4 6.015m 5.413m ~ 0.400
ParallelGetAndSetIfNotExists/50k_nodes-4 9.421m 7.467m ~ 0.100
ParallelGetAndSetIfNotExists/100k_nodes-4 12.82m 10.19m ~ 0.100
SequentialGetAndSetIfNotExists/1k_nodes-4 1.838m 1.819m ~ 0.400
SequentialGetAndSetIfNotExists/10k_nodes-4 8.903m 5.825m ~ 0.100
SequentialGetAndSetIfNotExists/50k_nodes-4 30.55m 14.29m ~ 0.100
SequentialGetAndSetIfNotExists/100k_nodes-4 56.25m 28.39m ~ 0.100
ProcessOwnBlockSubtreeNodesParallel/1k_nodes-4 2.194m 2.130m ~ 0.700
ProcessOwnBlockSubtreeNodesParallel/10k_nodes-4 9.053m 8.789m ~ 0.400
ProcessOwnBlockSubtreeNodesParallel/100k_nodes-4 14.93m 15.39m ~ 0.700
ProcessOwnBlockSubtreeNodesSequential/1k_nodes-4 1.875m 1.890m ~ 0.700
ProcessOwnBlockSubtreeNodesSequential/10k_nodes-4 11.59m 10.24m ~ 0.100
ProcessOwnBlockSubtreeNodesSequential/100k_nodes-4 76.60m 51.44m ~ 0.100
DiskTxMap_SetIfNotExists-4 4.311µ 4.380µ ~ 0.600
DiskTxMap_SetIfNotExists_Parallel-4 3.872µ 3.916µ ~ 0.700
DiskTxMap_ExistenceOnly-4 386.1n 394.5n ~ 0.400
Queue-4 196.9n 191.8n ~ 0.100
AtomicPointer-4 3.490n 3.633n ~ 0.100
ReorgOptimizations/DedupFilterPipeline/Old/10K-4 901.0µ 833.3µ ~ 0.100
ReorgOptimizations/DedupFilterPipeline/New/10K-4 828.0µ 792.8µ ~ 0.100
ReorgOptimizations/AllMarkFalse/Old/10K-4 108.8µ 108.8µ ~ 0.700
ReorgOptimizations/AllMarkFalse/New/10K-4 64.51µ 64.55µ ~ 1.000
ReorgOptimizations/HashSlicePool/Old/10K-4 58.03µ 52.13µ ~ 0.200
ReorgOptimizations/HashSlicePool/New/10K-4 11.10µ 10.95µ ~ 0.100
ReorgOptimizations/NodeFlags/Old/10K-4 5.121µ 4.944µ ~ 0.100
ReorgOptimizations/NodeFlags/New/10K-4 1.950µ 1.872µ ~ 0.200
ReorgOptimizations/DedupFilterPipeline/Old/100K-4 10.68m 10.76m ~ 1.000
ReorgOptimizations/DedupFilterPipeline/New/100K-4 11.49m 12.04m ~ 0.100
ReorgOptimizations/AllMarkFalse/Old/100K-4 1.120m 1.141m ~ 0.100
ReorgOptimizations/AllMarkFalse/New/100K-4 706.6µ 707.5µ ~ 0.400
ReorgOptimizations/HashSlicePool/Old/100K-4 492.7µ 520.6µ ~ 0.100
ReorgOptimizations/HashSlicePool/New/100K-4 203.5µ 215.4µ ~ 0.100
ReorgOptimizations/NodeFlags/Old/100K-4 51.23µ 51.13µ ~ 0.700
ReorgOptimizations/NodeFlags/New/100K-4 16.80µ 17.58µ ~ 0.100
TxMapSetIfNotExists-4 50.04n 49.68n ~ 0.400
TxMapSetIfNotExistsDuplicate-4 41.47n 41.39n ~ 0.700
ChannelSendReceive-4 590.6n 615.7n ~ 0.100
CalcBlockWork-4 552.2n 506.9n ~ 0.100
CalculateWork-4 698.2n 703.6n ~ 0.700
BuildBlockLocatorString_Helpers/Size_10-4 1.371µ 1.683µ ~ 1.000
BuildBlockLocatorString_Helpers/Size_100-4 12.80µ 12.78µ ~ 0.700
BuildBlockLocatorString_Helpers/Size_1000-4 128.6µ 129.0µ ~ 1.000
CatchupWithHeaderCache-4 104.2m 104.4m ~ 0.700
_prepareTxsPerLevel-4 402.8m 407.3m ~ 1.000
_prepareTxsPerLevelOrdered-4 3.567m 3.621m ~ 1.000
_prepareTxsPerLevel_Comparison/Original-4 397.1m 399.4m ~ 1.000
_prepareTxsPerLevel_Comparison/Optimized-4 3.544m 3.671m ~ 0.100
SubtreeSizes/10k_tx_4_per_subtree-4 990.9µ 997.7µ ~ 1.000
SubtreeSizes/10k_tx_16_per_subtree-4 232.4µ 234.3µ ~ 0.400
SubtreeSizes/10k_tx_64_per_subtree-4 55.84µ 56.06µ ~ 0.700
SubtreeSizes/10k_tx_256_per_subtree-4 13.77µ 14.09µ ~ 0.400
SubtreeSizes/10k_tx_512_per_subtree-4 6.937µ 6.942µ ~ 0.700
SubtreeSizes/10k_tx_1024_per_subtree-4 3.412µ 3.501µ ~ 0.600
SubtreeSizes/10k_tx_2k_per_subtree-4 1.689µ 1.738µ ~ 1.000
BlockSizeScaling/10k_tx_64_per_subtree-4 54.66µ 54.38µ ~ 0.400
BlockSizeScaling/10k_tx_256_per_subtree-4 13.71µ 13.91µ ~ 0.200
BlockSizeScaling/10k_tx_1024_per_subtree-4 3.401µ 3.425µ ~ 0.700
BlockSizeScaling/50k_tx_64_per_subtree-4 288.0µ 285.5µ ~ 1.000
BlockSizeScaling/50k_tx_256_per_subtree-4 68.95µ 69.48µ ~ 0.400
BlockSizeScaling/50k_tx_1024_per_subtree-4 16.97µ 17.25µ ~ 0.200
SubtreeAllocations/small_subtrees_exists_check-4 117.9µ 114.8µ ~ 0.200
SubtreeAllocations/small_subtrees_data_fetch-4 125.2µ 125.1µ ~ 1.000
SubtreeAllocations/small_subtrees_full_validation-4 239.9µ 237.1µ ~ 0.200
SubtreeAllocations/medium_subtrees_exists_check-4 6.934µ 6.991µ ~ 0.100
SubtreeAllocations/medium_subtrees_data_fetch-4 7.260µ 7.422µ ~ 0.100
SubtreeAllocations/medium_subtrees_full_validation-4 14.10µ 13.79µ ~ 0.400
SubtreeAllocations/large_subtrees_exists_check-4 1.657µ 1.656µ ~ 1.000
SubtreeAllocations/large_subtrees_data_fetch-4 1.761µ 1.766µ ~ 0.600
SubtreeAllocations/large_subtrees_full_validation-4 3.493µ 3.450µ ~ 0.800
_BufferPoolAllocation/16KB-4 3.932µ 4.342µ ~ 0.700
_BufferPoolAllocation/32KB-4 11.150µ 7.435µ ~ 0.100
_BufferPoolAllocation/64KB-4 20.09µ 15.47µ ~ 0.100
_BufferPoolAllocation/128KB-4 32.57µ 31.53µ ~ 0.200
_BufferPoolAllocation/512KB-4 135.7µ 126.0µ ~ 0.100
_BufferPoolConcurrent/32KB-4 21.00µ 19.27µ ~ 0.100
_BufferPoolConcurrent/64KB-4 33.03µ 29.93µ ~ 0.100
_BufferPoolConcurrent/512KB-4 161.3µ 157.5µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/16KB-4 681.2µ 673.7µ ~ 0.700
_SubtreeDeserializationWithBufferSizes/32KB-4 665.4µ 667.3µ ~ 0.700
_SubtreeDeserializationWithBufferSizes/64KB-4 664.0µ 663.9µ ~ 1.000
_SubtreeDeserializationWithBufferSizes/128KB-4 651.2µ 647.8µ ~ 0.700
_SubtreeDeserializationWithBufferSizes/512KB-4 655.9µ 668.5µ ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/16KB-4 38.51m 38.46m ~ 0.700
_SubtreeDataDeserializationWithBufferSizes/32KB-4 38.33m 38.73m ~ 0.200
_SubtreeDataDeserializationWithBufferSizes/64KB-4 38.63m 38.66m ~ 1.000
_SubtreeDataDeserializationWithBufferSizes/128KB-4 38.76m 38.61m ~ 0.400
_SubtreeDataDeserializationWithBufferSizes/512KB-4 38.82m 38.58m ~ 0.400
_PooledVsNonPooled/Pooled-4 821.0n 820.8n ~ 0.900
_PooledVsNonPooled/NonPooled-4 7.617µ 7.695µ ~ 0.700
_MemoryFootprint/Current_512KB_32concurrent-4 9.098µ 8.263µ ~ 0.200
_MemoryFootprint/Proposed_32KB_32concurrent-4 11.21µ 11.40µ ~ 0.400
_MemoryFootprint/Alternative_64KB_32concurrent-4 10.60µ 10.66µ ~ 0.700
StoreBlock_Sequential/BelowCSVHeight-4 333.8µ 337.7µ ~ 0.100
StoreBlock_Sequential/AboveCSVHeight-4 334.9µ 339.1µ ~ 0.100
GetUtxoHashes-4 255.0n 266.7n ~ 0.100
GetUtxoHashes_ManyOutputs-4 42.97µ 43.03µ ~ 0.700
_NewMetaDataFromBytes-4 229.1n 228.9n ~ 0.800
_Bytes-4 406.2n 403.2n ~ 0.700
_MetaBytes-4 138.6n 138.1n ~ 1.000

Threshold: >10% with p < 0.05 | Generated: 2026-05-22 13:31 UTC

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

Follow-up after closer look — approval still stands, but flagging one concern that's worth addressing before merge, plus a couple of follow-ups.

Concern (worth fixing before merge): cap-eviction holds the write lock across a synchronous validationClient.Validate gRPC call per insert at cap. Under sustained orphan flood, the lock is held one gRPC round-trip per evict, which stalls:

  • sm.orphanTxs.Len() from the Prometheus metric goroutine (every 5s)
  • processOrphanTransactionssm.orphanTxs.Items() snapshot

TTL clean() has the same antipattern but runs every 10 min; cap-evict runs per insert at cap, potentially hundreds-to-thousands/sec under attack.

Fix is short: run the eviction function outside m.mu — find + delete oldest under the lock, capture the value, drop the lock, then call evictionFn on the captured value. Or make orphan eviction-fn push to a buffered channel + worker.

Worth a follow-up issue (not blocking):

  • MaxOrphanTxSize is commented out in settings.go:96 — 100 × 10 MB ≈ 1 GB worst case. Bitcoin Core's equivalent is ~10 MB (per-entry size also capped).
  • Shared cap = one peer flooding 100+ orphans can displace all honest peers' pending orphans (oldest-first eviction). No per-peer accounting.
  • Negative legacy_maxOrphanTxs silently disables the cap (WithMaxSize gates on > 0) — should warn or clamp.
  • Other unbounded expiringmap callers worth a separate audit: blockvalidation.blockExistsCache (120min TTL, peer-driven), subtreevalidation.invalidSubtreeDeDuplicateMap (peer-driven).

Test gaps: cap=1 boundary, deterministic-ordering test (current relies on time.Sleep(2ms) between inserts), assertion that the correct oldest key is evicted.

@sonarqubecloud

Copy link
Copy Markdown

@ordishs ordishs requested a review from icellan May 22, 2026 14:20
@ordishs ordishs merged commit 7427939 into bsv-blockchain:main May 26, 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.

3 participants