Skip to content

fix(legacy): never announce block-originated transactions to legacy peers#1073

Merged
oskarszoon merged 3 commits into
bsv-blockchain:mainfrom
oskarszoon:fix/legacy-inblock-tx-announce
Jun 11, 2026
Merged

fix(legacy): never announce block-originated transactions to legacy peers#1073
oskarszoon merged 3 commits into
bsv-blockchain:mainfrom
oskarszoon:fix/legacy-inblock-tx-announce

Conversation

@oskarszoon

Copy link
Copy Markdown
Contributor

Split out of #1067 (fix 3) per review there, with the provenance flag reworked per @ordishs' feedback.

Problem

After legacy catchup reaches the tip and the FSM enters RUNNING, peers flood the node with getdata and pushTxMsg logs:

[pushTxMsg] Unable to fetch tx ... from transaction pool: TX_NOT_FOUND (30)

The txmeta Kafka topic is a cache-population stream: block validation, subtree validation, and legacy sync deliberately validate block/subtree txs through the validator to pre-warm the subtree-validation txmeta cache, so every such tx lands on the topic alongside fresh mempool txs. Legacy netsync consumed that stream as its tx announce source with only an IsCoinbase filter, INVing every synced and newly-validated block tx to peers. Peers check tx invs against their mempool, not the chain, so they getdata essentially everything — most of it long mined and often already pruned (spent txs get DeleteAtHeight, and catchup races the chain height past it). The found ones were worse: served to peers as if fresh mempool relay.

Fix

Carry provenance on the wire, explicitly:

  • validator.Options.InBlock / WithInBlock(true) — set only at the block-context call sites: subtreevalidation (Server.go ×2, check_block_subtrees.go ×2) and legacy netsync (handle_block.go ×3). NOT inferred from SkipPolicyChecks, which external gRPC/Kafka/propagation submitters may set on genuinely fresh transactions (the flaw in the first iteration of this change, caught in fix(legacy): prevent sync stalls from backpressure-triggered peer rotation and dropped orphan continuation #1067 review).
  • New optional bool in_block = 11 on ValidateTransactionRequest, plus the inBlock HTTP fallback query param — same propagation pattern as candidate_parent_median_time. The Kafka validation message path is mempool-only and defaults to false.
  • meta.Data.InBlock, bit 4 of the txmeta flags byte (both serializers). Existing consumers parse the flags byte with per-bit ops, so the new bit is inert for them; the subtree-validation cache stores the raw bytes unchanged.
  • Legacy netsync drops InBlock entries before the announce batcher: mined/subtree txs are never INVed as fresh mempool txs. Subtree-origin txs were already relayed via the propagation path on first validation, so this is dedup, not lost relay.

Testing

  • TestOptionsFromValidateRequest_InBlockRoundTrip / _InBlockDefaultsFalse: gRPC propagation; explicitly pins that SkipPolicyChecks=true alone does NOT set InBlock.
  • TestHTTPHandlerPath_InBlock_EndToEnd: HTTP fallback query → server parse.
  • Test_InBlockFlagRoundtrip: flag survives both txmeta serialization formats.
  • TestProcessTXmetaBatchMessage_SkipsInBlockTx: wire batch with in-block + mempool entries → only the mempool tx reaches the announce batcher.
  • go test -race clean on services/validator, services/legacy/netsync, stores/utxo/meta; services/subtreevalidation clean; go vet clean; golangci-lint reports only pre-existing prealloc findings in untouched subtreevalidation test files.

@github-actions

github-actions Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

🤖 Claude Code Review

Status: Complete

Current Review: No blocking issues found. Clean, well-scoped, well-tested change. Re-traced each layer against the current diff:

  • Wire/serialization parity — producer (sendTxMetaToKafka/batch paths → MetaBytes) and consumer (processTXmetaBatchMessageNewMetaDataFromBytes) both handle flags-byte bit 4 (0b10000). All four meta.Data serializers set/read it symmetrically; existing per-bit parsers leave the new bit inert, so it is backward compatible. The bit lives in entry content, so v1/v2 framing does not affect it.
  • Call-site coverageWithInBlock(true) is set at exactly the block/subtree-context sites (subtreevalidation ×4 option builders feeding ValidateWithOptions, legacy handle_block.go ×3). Block-validation provenance is covered transitively via subtreevalidation. Fresh-mempool/orphan paths (manager.go Validate calls, propagation, RPC) correctly leave it unset.
  • Explicit-not-inferred — keeping InBlock independent of SkipPolicyChecks is correct (external submitters set the latter on genuinely fresh txs); tests pin this invariant directly. The mutation happens only on the kafka payload after store/block-assembly, so it is not persisted.
  • Aerospike stores flags as separate bins, not the packed byte, so this transient flag is correctly absent there — no store-side change needed.

One design note (already acknowledged in the PR description, not a bug): dropping all InBlock entries before the announce batcher assumes subtree-origin txs also reached this node via propagation. A tx arriving only via subtree validation would not be INV'd to legacy peers — the intended dedup tradeoff, and the right call given the getdata flood it fixes.

History:

@github-actions

github-actions Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Benchmark Comparison Report

Baseline: main (unknown)

Current: PR-1073 (eefdc8e)

Summary

  • Regressions: 0
  • Improvements: 0
  • Unchanged: 132
  • Significance level: p < 0.05
All benchmark results (sec/op)
Benchmark Baseline Current Change p-value
_NewBlockFromBytes-4 2.004µ 1.810µ ~ 0.700
SplitSyncedParentMap_SetIfNotExists/256_buckets-4 59.49n 59.51n ~ 0.700
SplitSyncedParentMap_SetIfNotExists/16_buckets-4 59.46n 59.44n ~ 1.000
SplitSyncedParentMap_SetIfNotExists/1_bucket-4 59.42n 59.38n ~ 1.000
SplitSyncedParentMap_ConcurrentSetIfNotExists/256_buckets... 38.79n 39.98n ~ 0.700
SplitSyncedParentMap_ConcurrentSetIfNotExists/16_buckets_... 69.18n 69.22n ~ 0.700
SplitSyncedParentMap_ConcurrentSetIfNotExists/1_bucket_pa... 177.0n 165.7n ~ 0.700
MiningCandidate_Stringify_Short-4 256.9n 253.6n ~ 0.100
MiningCandidate_Stringify_Long-4 1.819µ 1.804µ ~ 0.100
MiningSolution_Stringify-4 946.2n 922.3n ~ 0.100
BlockInfo_MarshalJSON-4 1.808µ 1.752µ ~ 0.100
NewFromBytes-4 126.2n 124.6n ~ 0.100
AddTxBatchColumnar_Validation-4 2.302µ 2.393µ ~ 1.000
OffsetValidationLoop-4 724.0n 720.6n ~ 0.100
Mine_EasyDifficulty-4 59.76µ 60.69µ ~ 0.400
Mine_WithAddress-4 6.961µ 6.759µ ~ 0.100
BlockAssembler_AddTx-4 0.02162n 0.02158n ~ 1.000
AddNode-4 10.43 10.38 ~ 0.700
AddNodeWithMap-4 10.73 10.54 ~ 1.000
DiskTxMap_SetIfNotExists-4 3.891µ 3.832µ ~ 0.400
DiskTxMap_SetIfNotExists_Parallel-4 3.692µ 3.544µ ~ 0.700
DiskTxMap_ExistenceOnly-4 426.4n 340.1n ~ 1.000
Queue-4 186.0n 186.1n ~ 1.000
AtomicPointer-4 3.658n 3.682n ~ 0.700
TxMapSetIfNotExists-4 50.12n 52.10n ~ 0.200
TxMapSetIfNotExistsDuplicate-4 42.25n 41.72n ~ 0.400
ChannelSendReceive-4 576.0n 559.0n ~ 0.100
DirectSubtreeAdd/4_per_subtree-4 74.95n 73.54n ~ 1.000
DirectSubtreeAdd/64_per_subtree-4 40.89n 40.93n ~ 1.000
DirectSubtreeAdd/256_per_subtree-4 39.80n 40.45n ~ 0.400
DirectSubtreeAdd/1024_per_subtree-4 38.86n 38.51n ~ 0.100
DirectSubtreeAdd/2048_per_subtree-4 38.28n 38.02n ~ 0.100
SubtreeProcessorAdd/4_per_subtree-4 345.2n 337.8n ~ 0.400
SubtreeProcessorAdd/64_per_subtree-4 325.6n 319.1n ~ 0.400
SubtreeProcessorAdd/256_per_subtree-4 316.2n 314.1n ~ 1.000
SubtreeProcessorAdd/1024_per_subtree-4 315.2n 324.4n ~ 0.100
SubtreeProcessorAdd/2048_per_subtree-4 316.3n 328.2n ~ 0.200
SubtreeProcessorRotate/4_per_subtree-4 319.3n 319.9n ~ 1.000
SubtreeProcessorRotate/64_per_subtree-4 328.9n 325.6n ~ 1.000
SubtreeProcessorRotate/256_per_subtree-4 330.0n 331.7n ~ 1.000
SubtreeProcessorRotate/1024_per_subtree-4 325.4n 332.7n ~ 0.200
SubtreeNodeAddOnly/4_per_subtree-4 85.63n 86.59n ~ 0.400
SubtreeNodeAddOnly/64_per_subtree-4 63.81n 64.20n ~ 0.100
SubtreeNodeAddOnly/256_per_subtree-4 62.85n 63.25n ~ 0.100
SubtreeNodeAddOnly/1024_per_subtree-4 62.71n 63.05n ~ 0.100
SubtreeCreationOnly/4_per_subtree-4 131.4n 132.0n ~ 0.700
SubtreeCreationOnly/64_per_subtree-4 571.1n 549.8n ~ 0.100
SubtreeCreationOnly/256_per_subtree-4 1.937µ 1.827µ ~ 0.700
SubtreeCreationOnly/1024_per_subtree-4 6.079µ 5.861µ ~ 0.200
SubtreeCreationOnly/2048_per_subtree-4 10.93µ 10.51µ ~ 1.000
SubtreeProcessorOverheadBreakdown/64_per_subtree-4 328.5n 329.8n ~ 1.000
SubtreeProcessorOverheadBreakdown/1024_per_subtree-4 331.8n 340.6n ~ 0.700
ParallelGetAndSetIfNotExists/1k_nodes-4 15.33m 15.26m ~ 1.000
ParallelGetAndSetIfNotExists/10k_nodes-4 17.77m 20.50m ~ 0.100
ParallelGetAndSetIfNotExists/50k_nodes-4 18.12m 22.64m ~ 0.100
ParallelGetAndSetIfNotExists/100k_nodes-4 19.89m 20.51m ~ 0.700
SequentialGetAndSetIfNotExists/1k_nodes-4 10.70m 15.06m ~ 0.100
SequentialGetAndSetIfNotExists/10k_nodes-4 14.18m 18.88m ~ 0.100
SequentialGetAndSetIfNotExists/50k_nodes-4 19.92m 21.89m ~ 0.200
SequentialGetAndSetIfNotExists/100k_nodes-4 26.92m 29.22m ~ 0.100
ProcessOwnBlockSubtreeNodesParallel/1k_nodes-4 11.96m 11.91m ~ 1.000
ProcessOwnBlockSubtreeNodesParallel/10k_nodes-4 17.92m 17.79m ~ 1.000
ProcessOwnBlockSubtreeNodesParallel/100k_nodes-4 23.16m 23.44m ~ 0.400
ProcessOwnBlockSubtreeNodesSequential/1k_nodes-4 10.98m 11.29m ~ 0.700
ProcessOwnBlockSubtreeNodesSequential/10k_nodes-4 17.19m 17.87m ~ 0.400
ProcessOwnBlockSubtreeNodesSequential/100k_nodes-4 51.21m 53.21m ~ 0.100
CalcBlockWork-4 281.2n 284.2n ~ 1.000
CalculateWork-4 377.3n 383.8n ~ 0.700
CheckOldBlockIDs/on-chain-prefetch/1000-4 63.60µ 64.90µ ~ 0.100
CheckOldBlockIDs/off-chain-prefetch/1000-4 52.82µ 51.96µ ~ 1.000
CheckOldBlockIDs/on-chain-prefetch/10000-4 458.0µ 457.8µ ~ 0.700
CheckOldBlockIDs/off-chain-prefetch/10000-4 350.3µ 350.7µ ~ 0.700
BuildBlockLocatorString_Helpers/Size_10-4 1.349µ 1.441µ ~ 0.100
BuildBlockLocatorString_Helpers/Size_100-4 12.85µ 13.86µ ~ 0.100
BuildBlockLocatorString_Helpers/Size_1000-4 127.4µ 137.6µ ~ 0.100
CatchupWithHeaderCache-4 104.4m 104.5m ~ 0.200
SubtreeSizes/10k_tx_4_per_subtree-4 1.356m 1.360m ~ 1.000
SubtreeSizes/10k_tx_16_per_subtree-4 324.1µ 323.5µ ~ 1.000
SubtreeSizes/10k_tx_64_per_subtree-4 77.00µ 77.60µ ~ 0.700
SubtreeSizes/10k_tx_256_per_subtree-4 19.25µ 19.48µ ~ 0.400
SubtreeSizes/10k_tx_512_per_subtree-4 9.572µ 9.652µ ~ 0.700
SubtreeSizes/10k_tx_1024_per_subtree-4 4.738µ 4.789µ ~ 0.200
SubtreeSizes/10k_tx_2k_per_subtree-4 2.377µ 2.386µ ~ 1.000
BlockSizeScaling/10k_tx_64_per_subtree-4 75.91µ 75.05µ ~ 0.700
BlockSizeScaling/10k_tx_256_per_subtree-4 19.07µ 19.18µ ~ 0.700
BlockSizeScaling/10k_tx_1024_per_subtree-4 4.678µ 4.771µ ~ 0.100
BlockSizeScaling/50k_tx_64_per_subtree-4 396.9µ 401.1µ ~ 0.700
BlockSizeScaling/50k_tx_256_per_subtree-4 95.16µ 94.40µ ~ 0.700
BlockSizeScaling/50k_tx_1024_per_subtree-4 23.18µ 23.40µ ~ 1.000
SubtreeAllocations/small_subtrees_exists_check-4 164.7µ 156.8µ ~ 0.100
SubtreeAllocations/small_subtrees_data_fetch-4 164.1µ 164.0µ ~ 1.000
SubtreeAllocations/small_subtrees_full_validation-4 330.3µ 328.7µ ~ 1.000
SubtreeAllocations/medium_subtrees_exists_check-4 9.753µ 9.289µ ~ 0.100
SubtreeAllocations/medium_subtrees_data_fetch-4 9.524µ 9.592µ ~ 1.000
SubtreeAllocations/medium_subtrees_full_validation-4 19.18µ 19.50µ ~ 0.700
SubtreeAllocations/large_subtrees_exists_check-4 2.342µ 2.212µ ~ 0.100
SubtreeAllocations/large_subtrees_data_fetch-4 2.304µ 2.285µ ~ 0.100
SubtreeAllocations/large_subtrees_full_validation-4 4.925µ 4.764µ ~ 0.200
_BufferPoolAllocation/16KB-4 3.928µ 4.074µ ~ 0.100
_BufferPoolAllocation/32KB-4 8.117µ 11.258µ ~ 0.200
_BufferPoolAllocation/64KB-4 15.99µ 18.38µ ~ 0.700
_BufferPoolAllocation/128KB-4 29.14µ 37.20µ ~ 0.100
_BufferPoolAllocation/512KB-4 120.1µ 137.8µ ~ 0.100
_BufferPoolConcurrent/32KB-4 19.72µ 22.88µ ~ 0.100
_BufferPoolConcurrent/64KB-4 30.83µ 31.58µ ~ 0.200
_BufferPoolConcurrent/512KB-4 150.0µ 154.4µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/16KB-4 670.0µ 766.0µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/32KB-4 708.6µ 768.9µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/64KB-4 701.6µ 756.9µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/128KB-4 728.9µ 759.9µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/512KB-4 644.1µ 738.9µ ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/16KB-4 37.70m 37.56m ~ 0.400
_SubtreeDataDeserializationWithBufferSizes/32KB-4 36.92m 37.31m ~ 0.400
_SubtreeDataDeserializationWithBufferSizes/64KB-4 37.11m 37.20m ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/128KB-4 37.27m 36.97m ~ 0.400
_SubtreeDataDeserializationWithBufferSizes/512KB-4 36.93m 36.94m ~ 1.000
_PooledVsNonPooled/Pooled-4 830.8n 833.7n ~ 0.100
_PooledVsNonPooled/NonPooled-4 7.838µ 8.872µ ~ 0.100
_MemoryFootprint/Current_512KB_32concurrent-4 7.270µ 6.795µ ~ 0.200
_MemoryFootprint/Proposed_32KB_32concurrent-4 9.611µ 10.016µ ~ 0.100
_MemoryFootprint/Alternative_64KB_32concurrent-4 9.277µ 9.747µ ~ 0.200
_prepareTxsPerLevel-4 407.0m 407.3m ~ 1.000
_prepareTxsPerLevelOrdered-4 3.768m 3.916m ~ 0.400
_prepareTxsPerLevel_Comparison/Original-4 401.3m 401.6m ~ 0.700
_prepareTxsPerLevel_Comparison/Optimized-4 3.790m 4.060m ~ 0.700
StoreBlock_Sequential/BelowCSVHeight-4 326.2µ 323.7µ ~ 0.700
StoreBlock_Sequential/AboveCSVHeight-4 337.2µ 326.3µ ~ 0.400
GetUtxoHashes-4 266.8n 265.0n ~ 0.400
GetUtxoHashes_ManyOutputs-4 46.31µ 45.47µ ~ 1.000
_NewMetaDataFromBytes-4 228.2n 228.4n ~ 1.000
_Bytes-4 400.7n 399.3n ~ 0.700
_MetaBytes-4 137.5n 138.6n ~ 0.400

Threshold: >10% with p < 0.05 | Generated: 2026-06-11 10:21 UTC

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

Verified the fix end to end: all seven block-context Validate call sites carry WithInBlock(true), the flag is set only on the Kafka-published copy (after CreateInUtxoStore, so it never leaks into persisted UTXO records), bit 4 was previously unused and is inert for the subtree-validation cache, and rolling upgrades degrade gracefully in all old/new combinations. Ran the touched packages fresh with -race — meta, validator, and netsync all green; vet clean.

The txmeta Kafka topic is a cache-population stream: block validation
and legacy sync deliberately validate mined-block txs through the
validator to pre-warm the subtree-validation txmeta cache, so every
mined tx lands on the topic alongside fresh mempool txs. Legacy netsync
consumed that stream as its tx announce source with no origin marker,
INVing every synced and newly-validated block tx to peers once the FSM
reached RUNNING. Peers check tx invs against their mempool, not the
chain, so they getdata essentially everything — flooding pushTxMsg with
TX_NOT_FOUND for txs that are long mined and already pruned (spent txs
get DeleteAtHeight and catchup races the chain height past it).

Carry the origin on the wire: claim bit 4 of the txmeta flags byte as
Mined. The validator sets it from SkipPolicyChecks — only ever set when
validating transactions from an already-mined block — and netsync drops
flagged entries from the announce batcher. Existing consumers parse the
flags byte with per-bit ops, so the new bit is inert for them.
SkipPolicyChecks is also set by subtree validation for transactions
from announced subtrees — block candidates that are not mined yet — so
"Mined" overstated what the flag means. InBlock covers all setters:
the tx arrived as part of a block or announced subtree rather than via
mempool submission. Announce-filter semantics unchanged.
Deriving InBlock from SkipPolicyChecks was wrong: SkipPolicyChecks is
a general option that also arrives from external callers (gRPC
ValidateTransactionRequest, the Kafka validation message, propagation
forwarding), so a fresh mempool tx submitted with it set would have
been suppressed from relay — the inverse of the bug being fixed.

Thread provenance explicitly instead: new Options.InBlock /
WithInBlock(true), set only at the block-context call sites
(subtreevalidation x4, legacy netsync handle_block x3), carried over
gRPC as in_block and the HTTP fallback as inBlock. The Kafka
validation path is mempool-only and defaults to false.
@oskarszoon oskarszoon force-pushed the fix/legacy-inblock-tx-announce branch from 11eb7fa to 02761c5 Compare June 11, 2026 10:06
@sonarqubecloud

Copy link
Copy Markdown

@oskarszoon oskarszoon merged commit 8124be0 into bsv-blockchain:main Jun 11, 2026
34 checks passed
oskarszoon added a commit to oskarszoon/teranode that referenced this pull request Jun 11, 2026
…mber resolution flag to proto field 12

Conflicts resolved:
- validator_api.proto: bsv-blockchain#1073 took field 11 for in_block (merged upstream
  first, owns the number); unconfirmed_parents_at_candidate_height moves
  to field 12. Both fields kept.
- validator_api.pb.go: regenerated from the resolved proto.
- validator/Client.go: buildValidateTxRequest carries both InBlock
  (upstream) and UnconfirmedParentsAtCandidateHeight (this branch).

WIRE SKEW WARNING for the existing beta deployment: the beta build sends
the resolution flag as field 11, which post-merge means in_block. A beta
subtreevalidation talking to a post-merge validator would silently set
the wrong option (wedge returns, wrong provenance). Replace beta
deployments wholesale; do not mix beta and post-merge services.
oskarszoon added a commit to oskarszoon/teranode that referenced this pull request Jun 11, 2026
…r with candidate-height resolution

Replaces the per-block ParentMetadata accumulator (bsv-blockchain#1013) on the
block-validation path with the WithUnconfirmedParentsAtCandidateHeight
validator option (introduced for the legacy path in bsv-blockchain#1065), then deletes the
accumulator machinery.

In-block parents resolve the unconfirmedParentHeight sentinel to the candidate
block height inside validateTransaction — identical outcome to the accumulator
for genuine in-block parents, without the hot-path seeding, per-tx metadata
filtering, per-level mutex merges, or per-tx ParentMetadata wire payload. On a
5.24M-tx chained block this removes ~34% end-to-end overhead.

Safety:
- PoW (nBits + HasMetTargetDifficulty) now runs before subtree blessing in
  ValidateBlock, so the fail-open option cannot be triggered by a no-PoW header.
- Floater backstop: a parent that is unconfirmed and not in the block fails open
  at tx level, then block.Valid surfaces ErrBlockIncomplete. When caught up
  (FSM not CATCHINGBLOCKS/LEGACYSYNCING) that is a genuine floater
  (SetMinedMulti->MinedSet invariant), so the block is invalidated/rolled back at
  every acceptance sink (optimistic background, non-optimistic, revalidation
  worker); while syncing it stays incomplete and is retried, preserving bsv-blockchain#1031.
- Recorded parent BlockHeights are untouched (sentinel exact-match only), so
  fork/revalidation height resolution is unchanged.

Deprecates proto field 10 (parent_metadata) via reserved; deletes ParentTxMetadata.
Preserves the in_block provenance option (bsv-blockchain#1073, field 11); unconfirmed-parents
resolution stays field 12.

Closes bsv-blockchain#1066
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