Skip to content

fix(legacy): suppress tx relay while FSM != RUNNING#843

Merged
oskarszoon merged 1 commit into
bsv-blockchain:mainfrom
oskarszoon:fix/legacy-tx-relay-fsm-gate
May 12, 2026
Merged

fix(legacy): suppress tx relay while FSM != RUNNING#843
oskarszoon merged 1 commit into
bsv-blockchain:mainfrom
oskarszoon:fix/legacy-tx-relay-fsm-gate

Conversation

@oskarszoon

Copy link
Copy Markdown
Contributor

Summary

  • The legacy server's outbound transaction-relay paths (AnnounceNewTransactions, RelayInventory for tx invs) are not gated by FSM state. While the node is in LEGACYSYNCING / CATCHINGBLOCKS and its local chain tip is below the Genesis activation height, validator.checkOutputs accepts pre-Genesis-only outputs (notably P2SH). Re-broadcasting those tx to post-Genesis peers earns an instant ban (bad-txns-vout-p2sh BAN THRESHOLD EXCEEDED) on the BSV legacy network.
  • This PR adds an FSM gate (canRelayTx) to both outbound entry points. Block invs are still relayed in every state — only tx invs are suppressed.

Background incident

On bsva-ovh-teranode-eu-3 (mainnet, height ~293k; Genesis at 620538):

2026-05-11 16:17:13 [worker10-H-TxnValidatorPool] Misbehaving: 57.129.99.214:36734 peer=496846 (0 -> 100) reason: bad-txns-vout-p2sh BAN THRESHOLD EXCEEDED
2026-05-11 18:01:38 [worker64-H-TxnValidatorPool] Misbehaving: 57.129.99.214:60550 peer=498492 (0 -> 100) reason: bad-txns-vout-p2sh BAN THRESHOLD EXCEEDED

Second ban triggered immediately after a manual clearbanned. eu-3 chain state at the time of the ban was ~block 293030 (well below Genesis), so the validator treated P2SH as valid and the unguarded relay paths broadcast the tx to seed-mainnet-eu-1.

Trigger paths (both unguarded prior to this PR)

  • services/legacy/netsync/handle_block.go:185-195 — orphan-pool drain after each accepted block calls peerNotifier.AnnounceNewTransactions(acceptedTxs).
  • services/legacy/netsync/manager.go:1004 — direct tx accept after handleTxMsg.

Both funnel into server.AnnounceNewTransactions / server.RelayInventory in services/legacy/peer_server.go. Those two functions checked only P2P.ListenMode (added in #589 for the silent-mode kill switch) — no FSM gate.

Why this wasn't a regression

History check (git log -S 'AnnounceNewTransactions' and -S 'relayTransactions' on services/legacy/peer_server.go) shows the functions were introduced in the original WIP legacy commit (c33490517) and never gated. The symmetric inbound INV path did get an FSM gate in Sept 2024 (dc2971de8, 977071daa — "process transactions when in RUNNING mode"). The outbound side was simply missed. Bug stayed latent because eu-3-class nodes normally run at tip — it only fires when chain < Genesis, which is uncommon outside of fresh IBD from low height.

Implementation

  • New helper (s *server) canRelayTx() bool in peer_server.go next to BanPeer/RelayInventory. Calls blockchainClient.IsFSMCurrentState(ctx, FSMStateRUNNING). Fails closed on error.
  • AnnounceNewTransactions: early-return if !canRelayTx().
  • RelayInventory: same gate, but only when invVect.Type == wire.InvTypeTx. Block invs continue unconditionally so block sync still works during legacy/catch-up.

Performance

The check is cheap. blockchain.Client.GetFSMCurrentState (Client.go:1670) serves from a locally-cached atomic.Pointer[FSMStateType] (c.fmsState), updated by the FSM subscription handler (Client.go:188-190 on NotificationType_FSMState). Hot path = atomic load + dereference + bool compare (~5 ns). Cold path = one gRPC fallback before the first subscription event, then cached for the rest of the process lifetime. No per-tx RPC.

Test plan

services/legacy/peer_server_test.go:

  • TestCanRelayTx_FSMStates — table-driven across RUNNING / LEGACYSYNCING / CATCHINGBLOCKS / IDLE plus the FSM-error case.

  • TestCanRelayTx_NilBlockchainClient — defensive path returns true without panicking.

  • TestRelayInventory_SuppressesTxWhenNotRunning — regression test for the ban incident: tx inv must not hit relayInv when FSM = CATCHINGBLOCKS.

  • TestRelayInventory_RelaysTxWhenRunning — positive case: tx inv flows when RUNNING.

  • TestRelayInventory_AlwaysRelaysBlockInvs — guards against over-broad fix; block invs must flow in every state.

  • TestAnnounceNewTransactions_SuppressedWhenNotRunning — covers the public entry called from the orphan-pool drain.

  • go build ./services/legacy/...

  • go test -count=1 -race ./services/legacy/... → 330 passed

  • go vet ./services/legacy/...

Follow-ups (out of scope)

  • The validator's checkOutputs gating P2SH on localChainHeight rather than on tipHeight or max(localHeight, ActivationHeight) is the deeper issue: even with this PR, if the node receives a tx via direct gossip while in RUNNING but its chain tip drifts below Genesis (e.g. after a deep reorg), the same broadcast leak can occur. Worth a follow-up to make Genesis enforcement tip-aware (or gate the validator's mempool-accept path on FSM state as well).
  • Separate bug noted from the incident logs: FSM bounces between RUNNING and CATCHINGBLOCKS while still at height 293k. The "Run" event is being fired too eagerly somewhere in the catchup state machine; tracked separately.

The legacy server's outbound transaction-relay paths
(AnnounceNewTransactions, RelayInventory for tx invs) were ungated by
FSM state. When the node is in LEGACYSYNCING / CATCHINGBLOCKS and the
local chain tip sits below the Genesis activation height, the validator
accepts pre-Genesis-only outputs such as P2SH. Re-broadcasting those
tx to post-Genesis peers triggers an instant ban
(`bad-txns-vout-p2sh BAN THRESHOLD EXCEEDED`) on the BSV legacy network.

Observed on mainnet eu-3 (height ~293k, Genesis at 620538): two ban
events from a seed SV node within a single afternoon, the second
re-triggered immediately after a clearban.

Mirrors the inbound INV gate that already exists (manager.go
handleInvMsg + the kafka listener at manager.go:2342). Adds a
canRelayTx() helper backed by blockchain.Client's atomic FSM cache, so
the check costs an atomic load per tx. Fails closed on FSM read error.

Block invs continue to be relayed in all states (block sync needs them
during legacy/catchup).
@github-actions

github-actions Bot commented May 11, 2026

Copy link
Copy Markdown
Contributor

🤖 Claude Code Review

Status: Complete

Current Review:
No issues found. This PR implements a well-designed fix for the transaction relay ban issue with comprehensive test coverage.

Summary:

  • Root cause analysis: Accurate identification of the bug where pre-Genesis P2SH transactions were being relayed during sync, causing bans
  • Implementation: Clean FSM-gated relay logic that fails closed on errors and preserves block relay during all sync states
  • Test coverage: Six comprehensive tests covering all FSM states, error paths, defensive nil-check, and regression scenarios
  • Documentation accuracy: PR description correctly describes the atomic caching behavior (verified at Client.go:49 and 1671)
  • Performance claims: Confirmed that IsFSMCurrentState uses cached atomic.Pointer with fallback to gRPC (Client.go:1671-1681)

Code Quality:

  • Minimal diff principle followed: only adds the necessary FSM gate without refactoring
  • Fail-closed error handling: any FSM error suppresses relay (peer_server.go:2614)
  • Appropriate separation: tx invs gated, block invs continue unconditionally (peer_server.go:2629)
  • Defensive nil-check for blockchainClient included and tested

@sonarqubecloud

Copy link
Copy Markdown

@github-actions

Copy link
Copy Markdown
Contributor

Benchmark Comparison Report

Baseline: main (unknown)

Current: PR-843 (40f237e)

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.682µ 1.681µ ~ 0.800
SplitSyncedParentMap_SetIfNotExists/256_buckets-4 61.56n 61.60n ~ 0.600
SplitSyncedParentMap_SetIfNotExists/16_buckets-4 61.60n 61.96n ~ 0.700
SplitSyncedParentMap_SetIfNotExists/1_bucket-4 61.76n 61.75n ~ 1.000
SplitSyncedParentMap_ConcurrentSetIfNotExists/256_buckets... 31.45n 31.29n ~ 1.000
SplitSyncedParentMap_ConcurrentSetIfNotExists/16_buckets_... 52.85n 51.97n ~ 0.400
SplitSyncedParentMap_ConcurrentSetIfNotExists/1_bucket_pa... 115.7n 110.9n ~ 0.700
MiningCandidate_Stringify_Short-4 262.2n 263.9n ~ 0.100
MiningCandidate_Stringify_Long-4 1.901µ 1.906µ ~ 0.700
MiningSolution_Stringify-4 988.2n 980.8n ~ 1.000
BlockInfo_MarshalJSON-4 1.796µ 1.756µ ~ 0.100
NewFromBytes-4 157.6n 128.5n ~ 0.200
Mine_EasyDifficulty-4 65.55µ 65.66µ ~ 0.700
Mine_WithAddress-4 7.385µ 7.042µ ~ 0.100
BlockAssembler_AddTx-4 0.03039n 0.02628n ~ 0.200
AddNode-4 10.38 11.09 ~ 0.200
AddNodeWithMap-4 10.59 11.00 ~ 1.000
DirectSubtreeAdd/4_per_subtree-4 47.77n 46.26n ~ 0.700
DirectSubtreeAdd/64_per_subtree-4 22.99n 23.05n ~ 0.200
DirectSubtreeAdd/256_per_subtree-4 22.12n 22.02n ~ 0.400
DirectSubtreeAdd/1024_per_subtree-4 20.90n 20.88n ~ 0.600
DirectSubtreeAdd/2048_per_subtree-4 20.55n 20.53n ~ 0.500
SubtreeProcessorAdd/4_per_subtree-4 220.3n 219.8n ~ 1.000
SubtreeProcessorAdd/64_per_subtree-4 215.5n 219.3n ~ 0.400
SubtreeProcessorAdd/256_per_subtree-4 215.8n 216.1n ~ 0.800
SubtreeProcessorAdd/1024_per_subtree-4 213.0n 216.0n ~ 0.100
SubtreeProcessorAdd/2048_per_subtree-4 208.2n 210.4n ~ 0.100
SubtreeProcessorRotate/4_per_subtree-4 211.7n 210.2n ~ 0.500
SubtreeProcessorRotate/64_per_subtree-4 211.1n 210.4n ~ 0.100
SubtreeProcessorRotate/256_per_subtree-4 210.2n 210.4n ~ 0.800
SubtreeProcessorRotate/1024_per_subtree-4 210.0n 211.0n ~ 0.200
SubtreeNodeAddOnly/4_per_subtree-4 43.99n 44.00n ~ 0.700
SubtreeNodeAddOnly/64_per_subtree-4 28.41n 28.42n ~ 1.000
SubtreeNodeAddOnly/256_per_subtree-4 27.55n 27.61n ~ 0.700
SubtreeNodeAddOnly/1024_per_subtree-4 27.05n 27.04n ~ 0.500
SubtreeCreationOnly/4_per_subtree-4 88.51n 89.11n ~ 0.200
SubtreeCreationOnly/64_per_subtree-4 308.0n 302.8n ~ 0.400
SubtreeCreationOnly/256_per_subtree-4 1.039µ 1.105µ ~ 0.100
SubtreeCreationOnly/1024_per_subtree-4 3.269µ 3.364µ ~ 0.100
SubtreeCreationOnly/2048_per_subtree-4 5.972µ 6.256µ ~ 0.100
SubtreeProcessorOverheadBreakdown/64_per_subtree-4 209.3n 212.7n ~ 0.100
SubtreeProcessorOverheadBreakdown/1024_per_subtree-4 209.2n 211.3n ~ 0.100
ParallelGetAndSetIfNotExists/1k_nodes-4 425.9µ 427.1µ ~ 0.400
ParallelGetAndSetIfNotExists/10k_nodes-4 1.015m 1.034m ~ 0.100
ParallelGetAndSetIfNotExists/50k_nodes-4 5.169m 5.198m ~ 0.700
ParallelGetAndSetIfNotExists/100k_nodes-4 10.36m 10.35m ~ 1.000
SequentialGetAndSetIfNotExists/1k_nodes-4 487.7µ 485.4µ ~ 0.400
SequentialGetAndSetIfNotExists/10k_nodes-4 2.298m 2.289m ~ 1.000
SequentialGetAndSetIfNotExists/50k_nodes-4 8.569m 8.486m ~ 0.100
SequentialGetAndSetIfNotExists/100k_nodes-4 16.48m 16.42m ~ 0.400
ProcessOwnBlockSubtreeNodesParallel/1k_nodes-4 457.5µ 471.9µ ~ 0.200
ProcessOwnBlockSubtreeNodesParallel/10k_nodes-4 3.585m 3.596m ~ 0.400
ProcessOwnBlockSubtreeNodesParallel/100k_nodes-4 12.96m 13.07m ~ 0.700
ProcessOwnBlockSubtreeNodesSequential/1k_nodes-4 515.7µ 522.9µ ~ 0.700
ProcessOwnBlockSubtreeNodesSequential/10k_nodes-4 4.834m 4.861m ~ 0.200
ProcessOwnBlockSubtreeNodesSequential/100k_nodes-4 30.31m 30.51m ~ 0.100
DiskTxMap_SetIfNotExists-4 3.509µ 3.660µ ~ 0.300
DiskTxMap_SetIfNotExists_Parallel-4 3.756µ 3.435µ ~ 0.800
DiskTxMap_ExistenceOnly-4 324.7n 321.2n ~ 1.000
Queue-4 152.4n 152.8n ~ 0.500
AtomicPointer-4 2.826n 2.808n ~ 0.700
ReorgOptimizations/DedupFilterPipeline/Old/10K-4 628.8µ 660.1µ ~ 0.100
ReorgOptimizations/DedupFilterPipeline/New/10K-4 634.2µ 647.8µ ~ 0.100
ReorgOptimizations/AllMarkFalse/Old/10K-4 102.2µ 100.5µ ~ 0.200
ReorgOptimizations/AllMarkFalse/New/10K-4 50.05µ 49.58µ ~ 0.100
ReorgOptimizations/HashSlicePool/Old/10K-4 47.74µ 48.01µ ~ 0.700
ReorgOptimizations/HashSlicePool/New/10K-4 8.566µ 8.672µ ~ 0.400
ReorgOptimizations/NodeFlags/Old/10K-4 3.868µ 3.754µ ~ 0.200
ReorgOptimizations/NodeFlags/New/10K-4 1.292µ 1.285µ ~ 0.700
ReorgOptimizations/DedupFilterPipeline/Old/100K-4 7.681m 8.192m ~ 0.400
ReorgOptimizations/DedupFilterPipeline/New/100K-4 8.414m 7.884m ~ 0.200
ReorgOptimizations/AllMarkFalse/Old/100K-4 926.8µ 885.3µ ~ 0.100
ReorgOptimizations/AllMarkFalse/New/100K-4 545.1µ 548.5µ ~ 0.100
ReorgOptimizations/HashSlicePool/Old/100K-4 460.4µ 441.5µ ~ 0.100
ReorgOptimizations/HashSlicePool/New/100K-4 193.6µ 200.4µ ~ 0.400
ReorgOptimizations/NodeFlags/Old/100K-4 41.05µ 40.56µ ~ 0.100
ReorgOptimizations/NodeFlags/New/100K-4 14.36µ 14.44µ ~ 0.400
TxMapSetIfNotExists-4 35.67n 35.60n ~ 0.700
TxMapSetIfNotExistsDuplicate-4 30.00n 29.88n ~ 1.000
ChannelSendReceive-4 441.8n 461.1n ~ 0.100
CalcBlockWork-4 500.3n 499.0n ~ 1.000
CalculateWork-4 684.2n 681.6n ~ 0.800
BuildBlockLocatorString_Helpers/Size_10-4 1.292µ 1.293µ ~ 1.000
BuildBlockLocatorString_Helpers/Size_100-4 13.47µ 14.12µ ~ 0.700
BuildBlockLocatorString_Helpers/Size_1000-4 123.4µ 124.8µ ~ 1.000
CatchupWithHeaderCache-4 104.3m 104.2m ~ 0.700
_prepareTxsPerLevel-4 427.7m 442.1m ~ 1.000
_prepareTxsPerLevelOrdered-4 4.162m 3.787m ~ 0.100
_prepareTxsPerLevel_Comparison/Original-4 438.5m 428.2m ~ 0.700
_prepareTxsPerLevel_Comparison/Optimized-4 3.760m 3.740m ~ 0.100
_BufferPoolAllocation/16KB-4 3.296µ 3.411µ ~ 0.200
_BufferPoolAllocation/32KB-4 8.134µ 7.504µ ~ 1.000
_BufferPoolAllocation/64KB-4 16.37µ 15.75µ ~ 0.700
_BufferPoolAllocation/128KB-4 28.83µ 31.60µ ~ 0.100
_BufferPoolAllocation/512KB-4 103.9µ 115.0µ ~ 0.100
_BufferPoolConcurrent/32KB-4 16.88µ 16.79µ ~ 0.700
_BufferPoolConcurrent/64KB-4 26.68µ 26.42µ ~ 0.700
_BufferPoolConcurrent/512KB-4 140.4µ 138.4µ ~ 0.700
_SubtreeDeserializationWithBufferSizes/16KB-4 632.9µ 689.8µ ~ 0.700
_SubtreeDeserializationWithBufferSizes/32KB-4 624.7µ 660.9µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/64KB-4 627.8µ 639.1µ ~ 0.400
_SubtreeDeserializationWithBufferSizes/128KB-4 628.8µ 664.4µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/512KB-4 627.1µ 652.9µ ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/16KB-4 36.23m 36.18m ~ 1.000
_SubtreeDataDeserializationWithBufferSizes/32KB-4 35.97m 36.03m ~ 0.700
_SubtreeDataDeserializationWithBufferSizes/64KB-4 36.27m 35.96m ~ 0.700
_SubtreeDataDeserializationWithBufferSizes/128KB-4 36.20m 36.20m ~ 1.000
_SubtreeDataDeserializationWithBufferSizes/512KB-4 35.93m 35.81m ~ 0.400
_PooledVsNonPooled/Pooled-4 830.5n 831.2n ~ 0.500
_PooledVsNonPooled/NonPooled-4 6.590µ 7.124µ ~ 0.100
_MemoryFootprint/Current_512KB_32concurrent-4 7.273µ 7.317µ ~ 0.700
_MemoryFootprint/Proposed_32KB_32concurrent-4 9.633µ 9.529µ ~ 0.700
_MemoryFootprint/Alternative_64KB_32concurrent-4 9.162µ 9.078µ ~ 0.400
SubtreeSizes/10k_tx_4_per_subtree-4 1.470m 1.398m ~ 0.100
SubtreeSizes/10k_tx_16_per_subtree-4 324.9µ 336.6µ ~ 0.400
SubtreeSizes/10k_tx_64_per_subtree-4 81.25µ 79.46µ ~ 0.700
SubtreeSizes/10k_tx_256_per_subtree-4 21.10µ 20.09µ ~ 0.100
SubtreeSizes/10k_tx_512_per_subtree-4 9.897µ 10.079µ ~ 0.100
SubtreeSizes/10k_tx_1024_per_subtree-4 4.922µ 4.878µ ~ 0.400
SubtreeSizes/10k_tx_2k_per_subtree-4 2.495µ 2.521µ ~ 0.200
BlockSizeScaling/10k_tx_64_per_subtree-4 77.58µ 77.64µ ~ 0.700
BlockSizeScaling/10k_tx_256_per_subtree-4 20.17µ 19.97µ ~ 0.700
BlockSizeScaling/10k_tx_1024_per_subtree-4 4.913µ 4.929µ ~ 1.000
BlockSizeScaling/50k_tx_64_per_subtree-4 399.6µ 399.7µ ~ 1.000
BlockSizeScaling/50k_tx_256_per_subtree-4 100.76µ 99.39µ ~ 0.200
BlockSizeScaling/50k_tx_1024_per_subtree-4 24.92µ 24.40µ ~ 0.700
SubtreeAllocations/small_subtrees_exists_check-4 163.3µ 166.1µ ~ 0.700
SubtreeAllocations/small_subtrees_data_fetch-4 169.6µ 168.8µ ~ 0.700
SubtreeAllocations/small_subtrees_full_validation-4 341.3µ 332.4µ ~ 0.700
SubtreeAllocations/medium_subtrees_exists_check-4 9.915µ 9.677µ ~ 0.400
SubtreeAllocations/medium_subtrees_data_fetch-4 10.49µ 10.63µ ~ 0.700
SubtreeAllocations/medium_subtrees_full_validation-4 19.96µ 20.05µ ~ 1.000
SubtreeAllocations/large_subtrees_exists_check-4 2.443µ 2.438µ ~ 0.400
SubtreeAllocations/large_subtrees_data_fetch-4 2.673µ 2.615µ ~ 1.000
SubtreeAllocations/large_subtrees_full_validation-4 5.118µ 5.106µ ~ 1.000
StoreBlock_Sequential/BelowCSVHeight-4 338.0µ 337.0µ ~ 0.700
StoreBlock_Sequential/AboveCSVHeight-4 320.7µ 337.4µ ~ 0.100
GetUtxoHashes-4 258.4n 255.8n ~ 1.000
GetUtxoHashes_ManyOutputs-4 43.49µ 45.16µ ~ 0.100
_NewMetaDataFromBytes-4 239.2n 237.1n ~ 0.700
_Bytes-4 616.9n 616.5n ~ 0.700
_MetaBytes-4 554.9n 553.6n ~ 0.700

Threshold: >10% with p < 0.05 | Generated: 2026-05-11 18:38 UTC

oskarszoon added a commit that referenced this pull request May 12, 2026
The legacy server's outbound transaction-relay paths
(AnnounceNewTransactions, RelayInventory for tx invs) were not gated by
FSM state. While the node is in LEGACYSYNCING / CATCHINGBLOCKS and its
local chain tip is below the Genesis activation height,
validator.checkOutputs accepts pre-Genesis-only outputs (notably P2SH).
Re-broadcasting those tx to post-Genesis peers earns an instant ban
(bad-txns-vout-p2sh BAN THRESHOLD EXCEEDED) on the BSV legacy network.

Observed on bsva-ovh-teranode-eu-3 (mainnet, height ~293k, Genesis at
620538): peer 57.129.99.214 banned 0->100 twice in two hours, second
ban immediately after a manual clearbanned.

Adds an FSM gate (canRelayTx) to both outbound entry points. Block
invs are still relayed in every state — only tx invs are suppressed.

The check is cheap: blockchain.Client serves FSM state from a locally-
cached atomic.Pointer, so this is ~5 ns on the hot path with no per-tx
RPC.
oskarszoon added a commit that referenced this pull request May 12, 2026
Block the FSMEventType_RUN transition in Blockchain.SendFSMEvent
whenever the local chain tip is below the network's highest hard-coded
checkpoint. Catchup callers continue to log the rejection and re-enter
CATCHINGBLOCKS, so the node stops bouncing between states while mid-IBD.

Networks with no checkpoints (regtest, brand-new chains) bypass the
gate. Mainnet's highest is currently 938000.

Follow-up to #843. The ban incident on bsva-ovh-teranode-eu-3 (chain
tip ~293k, mainnet highest checkpoint 938000) showed 30 RUN events in
2.5 hours from blockvalidation/catchup's restoreFSMState. The defer
unconditionally transitions to RUN on every catchup chunk exit,
regardless of whether the chain is caught up. Each RUN window allowed
the legacy service to emit at least one P2SH-bearing tx inv to seed-
mainnet-eu-1, earning bad-txns-vout-p2sh BAN THRESHOLD EXCEEDED.

The right surgical fix is at the FSM chokepoint rather than rewriting
the catchup state machine. Pair with #843: 843 stops outbound tx relay
while not RUNNING; this stops bogus RUN transitions while below
checkpoint.

Includes a refactor pulling HighestCheckpointHeight out of blockchain
and blockvalidation into a single shared helper.
oskarszoon added a commit that referenced this pull request May 12, 2026
Block the FSMEventType_RUN transition in Blockchain.SendFSMEvent
whenever the local chain tip is below the network's highest hard-coded
checkpoint. Catchup callers continue to log the rejection and re-enter
CATCHINGBLOCKS, so the node stops bouncing between states while
mid-IBD. Networks with no checkpoints (regtest, brand-new chains)
bypass the gate. Mainnet's highest is currently 938000, testnet3's is
1700000.

Also removes the unconditional Init-time RUN flip in
blockvalidation.Server.Init (added in #3672). It bypassed the gate and
was the load-bearing reason the node could lie about being RUNNING
during IBD. Crash recovery from a stuck CATCHINGBLOCKS state is
already covered by legacy startSync and restoreFSMState (both now
subject to the gate).

Follow-up to #843. The ban incident on bsva-ovh-teranode-eu-3 (chain
tip ~293k, mainnet highest checkpoint 938000) showed 30 RUN events in
2.5 hours from blockvalidation/catchup's restoreFSMState. Each RUN
window allowed the legacy service to emit at least one P2SH-bearing tx
inv to seed-mainnet-eu-1, earning bad-txns-vout-p2sh BAN THRESHOLD
EXCEEDED.

The right surgical fix is at the FSM chokepoint plus removing the
known-bad Init flip, rather than rewriting the catchup state machine.
Pair with #843: 843 stops outbound tx relay while not RUNNING; this
stops bogus RUN transitions while below checkpoint.

Includes a refactor pulling HighestCheckpointHeight out of blockchain
and blockvalidation into a single shared blockchain.HighestCheckpointHeight
helper.
@oskarszoon oskarszoon merged commit b040705 into bsv-blockchain:main May 12, 2026
25 checks passed
@oskarszoon oskarszoon self-assigned this May 12, 2026
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