Skip to content

fix: correct conflict resolution and assembly recovery during reorg reset#679

Merged
icellan merged 1 commit into
mainfrom
fix/reorg-reset-conflict-resolution
Apr 13, 2026
Merged

fix: correct conflict resolution and assembly recovery during reorg reset#679
icellan merged 1 commit into
mainfrom
fix/reorg-reset-conflict-resolution

Conversation

@icellan

@icellan icellan commented Apr 9, 2026

Copy link
Copy Markdown
Contributor

Problem

When handleReorg falls back to reset() — either because the reorg is too large, contains an invalid block, or subtreeProcessor.Reorg() returns an error — two distinct bugs cause the block assembly to end up in an incorrect state after the reset completes.

Bug 1: Assembly transactions silently lost after reset

SubtreeProcessor.reset() clears the in-memory assembly (chainedSubtrees, currentSubtree, currentTxMap) without first marking those transactions as NOT on longest chain in the UTXO store.

The unmined iterator used by loadUnminedTransactions() filters by WHERE unmined_since IS NOT NULL. Any assembly transaction whose unmined_since is NULL in the UTXO store — which happens when a competing fork's BlockValidation processed it, inserting it with UnminedSince=0 — is invisible to this scan. The transaction is silently dropped from the rebuilt assembly and never mined.

The reorgBlocks() path already handles this correctly: before finalising the reorg it calls markNotOnLongestChain() for all current assembly transactions (lines 2665–2710). The reset path was missing the equivalent step.

Bug 2: Conflicts not detected after reset

BlockAssembler.reset() called loadUnminedTransactions with validateInputs=false. The getConflictingNodes() step that precedes it reads only pre-stored conflicting markers from block subtree files. If a moveForward block was validated before the conflicting assembly tx existed (so no marker was written into the subtree file), the conflict goes undetected.

validateUnminedTxInputs() independently verifies each unmined tx's inputs against the UTXO store's SpendingData. If SpendingData.TxID points to a different tx the input is already spent elsewhere and the tx is marked conflicting and excluded. This check has no dependency on subtree file markers, so it catches exactly the gaps left by getConflictingNodes().

Fix

SubtreeProcessor.reset() (services/blockassembly/subtreeprocessor/SubtreeProcessor.go):

Before clearing the assembly state, collect all transaction hashes from chainedSubtrees and currentSubtree and call markNotOnLongestChain(). This is idempotent — transactions that already have unmined_since set are unchanged — and directly mirrors what reorgBlocks() does.

BlockAssembler.handleReorg() (services/blockassembly/BlockAssembler.go):

Both reset() call sites in handleReorg now pass validateInputs=true. This enables validateUnminedTxInputs() inside loadUnminedTransactions, which catches any spending conflicts that getConflictingNodes() missed due to absent subtree markers. The existing gRPC-triggered reset path (resetReq.ValidateInputs) is unchanged.

Tests

Three new tests added using TDD — each written as failing (RED) first, then the fix applied to make it pass (GREEN):

Unit: TestResetMarksAssemblyTxsAsNotOnLongestChainBeforeClearing

(services/blockassembly/subtreeprocessor/reset_reorg_test.go)

Adds a tx to the assembly, manually sets its unmined_since to NULL in the UTXO store (simulating a competing fork), calls Reset(), and asserts the tx now has unmined_since set. Without the fix, the tx remains NULL and is lost from the rebuilt assembly.

Unit: TestReset_ConflictDetectionViaValidateInputs

(services/blockassembly/BlockAssembler_test.go)

Creates a tx in the unmined pool whose parent output's SpendingData points to a different (winning) tx, then calls reset(ctx, false, true). Asserts the conflicting tx is not loaded into block assembly. Without the fix (validateInputs=false), the conflict is missed and the tx incorrectly re-enters assembly.

E2E: TestReorgResetRecoversAssemblyTxPostgres/Aerospike

(test/sequentialtest/double_spend/11_reorg_reset_recovers_assembly_tx_test.go)

End-to-end sequential test that sets unmined_since=NULL on an assembly tx (simulating it being marked mined by a competing fork), forces a block assembly reset, and verifies the tx is recovered back into assembly. Follows the same style as the other numbered tests in test/sequentialtest/double_spend/.

Test plan

  • go test -race -tags testtxmetacache -run TestResetMarksAssemblyTxsAsNotOnLongestChainBeforeClearing ./services/blockassembly/subtreeprocessor/ — PASS
  • go test -race -tags testtxmetacache -run TestReset_ConflictDetectionViaValidateInputs ./services/blockassembly/ — PASS
  • make sequentialtest TEST=TestReorgResetRecoversAssemblyTxPostgres — PASS
  • go test -race -tags testtxmetacache ./services/blockassembly/... — all existing tests PASS

…eset

Two related bugs in the reset fallback path triggered by a failed or
large reorg:

1. SubtreeProcessor.reset() cleared the in-memory block assembly
   (chainedSubtrees, currentSubtree) without first marking those
   transactions as NOT on longest chain in the UTXO store. Any assembly
   tx whose unmined_since was NULL — set by a competing fork's
   BlockValidation — would be invisible to the unmined_since index scan
   inside loadUnminedTransactions() and silently dropped from the
   rebuilt assembly. The Reorg path already handles this correctly at
   lines 2665-2710 (markNotOnLongestChain). The same step is now added
   to reset() before state is cleared, mirroring the Reorg path exactly.

2. BlockAssembler.reset() called loadUnminedTransactions with
   validateInputs=false. The getConflictingNodes() step reads only
   pre-stored conflicting markers from block subtree files; if a
   moveForward block was validated before the conflicting assembly tx
   existed, no marker was stored and the conflict went undetected.
   validateUnminedTxInputs() independently checks each unmined tx's
   inputs against the UTXO store's SpendingData, catching any gaps left
   by getConflictingNodes(). handleReorg now passes validateInputs=true
   at both reset() call sites to enable this check on every reorg reset.

Tests added:
- TestResetMarksAssemblyTxsAsNotOnLongestChainBeforeClearing: unit test
  verifying that reset() marks assembly txs not-on-longest-chain before
  clearing state so loadUnminedTransactions() can recover them.
- TestReset_ConflictDetectionViaValidateInputs: unit test verifying that
  reset() with validateInputs=true excludes txs whose inputs are already
  spent by a different mined tx.
- 11_reorg_reset_recovers_assembly_tx_test.go: e2e sequential test
  verifying that a tx in assembly with unmined_since=NULL (set by a
  competing fork) is recovered into block assembly after a reset.
@icellan icellan self-assigned this Apr 9, 2026
@icellan icellan requested review from ordishs and oskarszoon April 9, 2026 10:27
@github-actions

github-actions Bot commented Apr 9, 2026

Copy link
Copy Markdown
Contributor

🤖 Claude Code Review

Status: Complete


This PR correctly fixes two subtle but critical bugs in the block assembly reset logic during reorg handling. The implementation is well-documented, thoroughly tested, and follows the project's TDD approach.

Strengths:

  • Clear problem statements in PR description with exact root cause analysis
  • TDD approach: tests written first (RED), then fixes applied (GREEN)
  • Three-tier test coverage: unit (SubtreeProcessor), unit (BlockAssembler), and E2E
  • Fix mirrors existing reorgBlocks() pattern (lines 2715-2748)
  • Excellent inline documentation explaining the "why" of each fix

Code Quality:

  • Follows project conventions (single-line logs, descriptive naming)
  • Idempotent operations prevent duplicate work
  • Appropriate error handling with context wrapping
  • Comments correctly reference actual line ranges in existing code

No issues found - the implementation is correct and ready for merge.

@sonarqubecloud

sonarqubecloud Bot commented Apr 9, 2026

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
66.7% Coverage on New Code (required ≥ 80%)

See analysis details on SonarQube Cloud

@github-actions

github-actions Bot commented Apr 9, 2026

Copy link
Copy Markdown
Contributor

Benchmark Comparison Report

Baseline: main (unknown)

Current: PR-679 (a783276)

Summary

  • Regressions: 0
  • Improvements: 0
  • Unchanged: 153
  • Significance level: p < 0.05
All benchmark results (sec/op)
Benchmark Baseline Current Change p-value
_NewBlockFromBytes-4 1.452µ 1.486µ ~ 0.400
SplitSyncedParentMap_SetIfNotExists/256_buckets-4 60.12n 59.66n ~ 0.700
SplitSyncedParentMap_SetIfNotExists/16_buckets-4 59.38n 59.37n ~ 1.000
SplitSyncedParentMap_SetIfNotExists/1_bucket-4 62.51n 59.42n ~ 0.200
SplitSyncedParentMap_ConcurrentSetIfNotExists/256_buckets... 45.95n 36.26n ~ 0.700
SplitSyncedParentMap_ConcurrentSetIfNotExists/16_buckets_... 74.96n 68.79n ~ 0.100
SplitSyncedParentMap_ConcurrentSetIfNotExists/1_bucket_pa... 177.2n 193.0n ~ 0.100
MiningCandidate_Stringify_Short-4 259.2n 264.2n ~ 0.400
MiningCandidate_Stringify_Long-4 1.867µ 1.825µ ~ 0.400
MiningSolution_Stringify-4 949.7n 977.4n ~ 0.700
BlockInfo_MarshalJSON-4 1.766µ 1.765µ ~ 1.000
NewFromBytes-4 125.0n 141.8n ~ 0.100
Mine_EasyDifficulty-4 58.36µ 58.43µ ~ 0.700
Mine_WithAddress-4 4.926µ 4.924µ ~ 0.700
BlockAssembler_AddTx-4 0.02775n 0.02751n ~ 0.700
AddNode-4 10.46 11.02 ~ 0.400
AddNodeWithMap-4 10.85 10.69 ~ 1.000
DirectSubtreeAdd/4_per_subtree-4 62.27n 61.65n ~ 1.000
DirectSubtreeAdd/64_per_subtree-4 30.35n 30.07n ~ 0.700
DirectSubtreeAdd/256_per_subtree-4 28.90n 28.31n ~ 0.100
DirectSubtreeAdd/1024_per_subtree-4 27.89n 27.17n ~ 0.100
DirectSubtreeAdd/2048_per_subtree-4 27.57n 26.92n ~ 0.100
SubtreeProcessorAdd/4_per_subtree-4 300.2n 299.3n ~ 0.100
SubtreeProcessorAdd/64_per_subtree-4 299.4n 296.7n ~ 0.700
SubtreeProcessorAdd/256_per_subtree-4 301.0n 297.7n ~ 0.200
SubtreeProcessorAdd/1024_per_subtree-4 299.6n 295.2n ~ 0.100
SubtreeProcessorAdd/2048_per_subtree-4 299.0n 295.3n ~ 0.100
SubtreeProcessorRotate/4_per_subtree-4 304.9n 301.1n ~ 0.700
SubtreeProcessorRotate/64_per_subtree-4 302.0n 302.4n ~ 1.000
SubtreeProcessorRotate/256_per_subtree-4 300.7n 299.0n ~ 0.200
SubtreeProcessorRotate/1024_per_subtree-4 299.1n 303.4n ~ 0.100
SubtreeNodeAddOnly/4_per_subtree-4 63.57n 64.25n ~ 0.700
SubtreeNodeAddOnly/64_per_subtree-4 38.67n 38.66n ~ 1.000
SubtreeNodeAddOnly/256_per_subtree-4 37.62n 38.92n ~ 0.200
SubtreeNodeAddOnly/1024_per_subtree-4 37.10n 37.10n ~ 1.000
SubtreeCreationOnly/4_per_subtree-4 139.6n 141.3n ~ 0.400
SubtreeCreationOnly/64_per_subtree-4 616.2n 607.7n ~ 1.000
SubtreeCreationOnly/256_per_subtree-4 2.131µ 2.110µ ~ 0.100
SubtreeCreationOnly/1024_per_subtree-4 7.650µ 7.688µ ~ 0.800
SubtreeCreationOnly/2048_per_subtree-4 14.49µ 14.49µ ~ 0.700
SubtreeProcessorOverheadBreakdown/64_per_subtree-4 297.6n 295.5n ~ 1.000
SubtreeProcessorOverheadBreakdown/1024_per_subtree-4 300.5n 294.9n ~ 0.100
ParallelGetAndSetIfNotExists/1k_nodes-4 928.0µ 953.6µ ~ 0.100
ParallelGetAndSetIfNotExists/10k_nodes-4 1.857m 1.856m ~ 1.000
ParallelGetAndSetIfNotExists/50k_nodes-4 8.181m 8.117m ~ 0.100
ParallelGetAndSetIfNotExists/100k_nodes-4 15.79m 15.90m ~ 0.100
SequentialGetAndSetIfNotExists/1k_nodes-4 754.4µ 758.6µ ~ 0.700
SequentialGetAndSetIfNotExists/10k_nodes-4 2.961m 2.946m ~ 0.700
SequentialGetAndSetIfNotExists/50k_nodes-4 11.05m 10.98m ~ 0.700
SequentialGetAndSetIfNotExists/100k_nodes-4 20.72m 20.94m ~ 0.700
ProcessOwnBlockSubtreeNodesParallel/1k_nodes-4 969.8µ 977.8µ ~ 0.400
ProcessOwnBlockSubtreeNodesParallel/10k_nodes-4 4.628m 4.629m ~ 0.700
ProcessOwnBlockSubtreeNodesParallel/100k_nodes-4 18.90m 19.11m ~ 0.100
ProcessOwnBlockSubtreeNodesSequential/1k_nodes-4 803.0µ 813.4µ ~ 0.200
ProcessOwnBlockSubtreeNodesSequential/10k_nodes-4 5.979m 6.112m ~ 0.100
ProcessOwnBlockSubtreeNodesSequential/100k_nodes-4 41.02m 40.38m ~ 0.400
DiskTxMap_SetIfNotExists-4 3.498µ 3.539µ ~ 0.100
DiskTxMap_SetIfNotExists_Parallel-4 3.339µ 3.376µ ~ 1.000
DiskTxMap_ExistenceOnly-4 300.6n 300.2n ~ 0.700
Queue-4 195.4n 199.4n ~ 0.100
AtomicPointer-4 4.320n 3.992n ~ 0.200
ReorgOptimizations/DedupFilterPipeline/Old/10K-4 914.4µ 931.7µ ~ 0.200
ReorgOptimizations/DedupFilterPipeline/New/10K-4 905.9µ 900.5µ ~ 0.700
ReorgOptimizations/AllMarkFalse/Old/10K-4 118.9µ 118.2µ ~ 0.700
ReorgOptimizations/AllMarkFalse/New/10K-4 61.76µ 62.35µ ~ 0.700
ReorgOptimizations/HashSlicePool/Old/10K-4 68.23µ 59.30µ ~ 0.100
ReorgOptimizations/HashSlicePool/New/10K-4 11.51µ 11.39µ ~ 0.200
ReorgOptimizations/NodeFlags/Old/10K-4 5.531µ 5.187µ ~ 0.400
ReorgOptimizations/NodeFlags/New/10K-4 1.933µ 1.811µ ~ 0.100
ReorgOptimizations/DedupFilterPipeline/Old/100K-4 9.940m 9.703m ~ 0.100
ReorgOptimizations/DedupFilterPipeline/New/100K-4 10.212m 9.521m ~ 0.100
ReorgOptimizations/AllMarkFalse/Old/100K-4 1.181m 1.146m ~ 0.400
ReorgOptimizations/AllMarkFalse/New/100K-4 680.2µ 681.6µ ~ 0.700
ReorgOptimizations/HashSlicePool/Old/100K-4 602.3µ 611.0µ ~ 0.400
ReorgOptimizations/HashSlicePool/New/100K-4 314.5µ 316.1µ ~ 1.000
ReorgOptimizations/NodeFlags/Old/100K-4 56.72µ 53.44µ ~ 0.400
ReorgOptimizations/NodeFlags/New/100K-4 19.71µ 19.52µ ~ 0.400
TxMapSetIfNotExists-4 51.47n 51.89n ~ 1.000
TxMapSetIfNotExistsDuplicate-4 37.94n 37.99n ~ 0.700
ChannelSendReceive-4 634.1n 613.8n ~ 0.100
CalcBlockWork-4 476.1n 472.3n ~ 0.400
CalculateWork-4 664.7n 673.2n ~ 1.000
BuildBlockLocatorString_Helpers/Size_10-4 1.296µ 1.297µ ~ 0.700
BuildBlockLocatorString_Helpers/Size_100-4 12.33µ 12.43µ ~ 0.100
BuildBlockLocatorString_Helpers/Size_1000-4 153.3µ 152.6µ ~ 1.000
CatchupWithHeaderCache-4 104.0m 104.0m ~ 0.700
_BufferPoolAllocation/16KB-4 3.827µ 3.559µ ~ 0.700
_BufferPoolAllocation/32KB-4 7.468µ 9.163µ ~ 0.100
_BufferPoolAllocation/64KB-4 14.38µ 16.69µ ~ 0.100
_BufferPoolAllocation/128KB-4 28.00µ 33.20µ ~ 0.100
_BufferPoolAllocation/512KB-4 113.9µ 114.2µ ~ 0.400
_BufferPoolConcurrent/32KB-4 19.43µ 17.21µ ~ 0.100
_BufferPoolConcurrent/64KB-4 27.90µ 27.80µ ~ 0.700
_BufferPoolConcurrent/512KB-4 146.4µ 142.0µ ~ 0.700
_SubtreeDeserializationWithBufferSizes/16KB-4 652.4µ 613.0µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/32KB-4 609.4µ 610.8µ ~ 0.400
_SubtreeDeserializationWithBufferSizes/64KB-4 630.6µ 611.2µ ~ 0.200
_SubtreeDeserializationWithBufferSizes/128KB-4 659.3µ 617.9µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/512KB-4 666.6µ 634.7µ ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/16KB-4 35.43m 35.55m ~ 1.000
_SubtreeDataDeserializationWithBufferSizes/32KB-4 35.47m 34.82m ~ 0.700
_SubtreeDataDeserializationWithBufferSizes/64KB-4 35.56m 35.16m ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/128KB-4 35.26m 35.07m ~ 0.400
_SubtreeDataDeserializationWithBufferSizes/512KB-4 35.26m 35.55m ~ 0.700
_PooledVsNonPooled/Pooled-4 734.0n 740.1n ~ 0.400
_PooledVsNonPooled/NonPooled-4 6.611µ 7.248µ ~ 0.200
_MemoryFootprint/Current_512KB_32concurrent-4 6.520µ 6.548µ ~ 0.400
_MemoryFootprint/Proposed_32KB_32concurrent-4 9.099µ 9.048µ ~ 0.700
_MemoryFootprint/Alternative_64KB_32concurrent-4 8.723µ 8.891µ ~ 0.200
SubtreeProcessor/100_tx_64_per_subtree-4 79.21m 79.68m ~ 0.200
SubtreeProcessor/500_tx_64_per_subtree-4 386.3m 384.1m ~ 1.000
SubtreeProcessor/500_tx_256_per_subtree-4 396.7m 392.7m ~ 0.200
SubtreeProcessor/1k_tx_64_per_subtree-4 768.9m 759.9m ~ 0.100
SubtreeProcessor/1k_tx_256_per_subtree-4 783.4m 774.8m ~ 0.400
StreamingProcessorPhases/FilterValidated/100_tx-4 2.676m 2.705m ~ 0.200
StreamingProcessorPhases/ClassifyProcess/100_tx-4 238.7µ 237.8µ ~ 1.000
StreamingProcessorPhases/FilterValidated/500_tx-4 13.26m 13.40m ~ 0.200
StreamingProcessorPhases/ClassifyProcess/500_tx-4 602.8µ 603.6µ ~ 1.000
StreamingProcessorPhases/FilterValidated/1k_tx-4 26.52m 27.07m ~ 0.100
StreamingProcessorPhases/ClassifyProcess/1k_tx-4 1.076m 1.061m ~ 0.400
SubtreeSizes/10k_tx_4_per_subtree-4 1.354m 1.330m ~ 0.700
SubtreeSizes/10k_tx_16_per_subtree-4 331.5µ 330.7µ ~ 0.400
SubtreeSizes/10k_tx_64_per_subtree-4 77.11µ 76.24µ ~ 0.200
SubtreeSizes/10k_tx_256_per_subtree-4 19.14µ 19.25µ ~ 0.700
SubtreeSizes/10k_tx_512_per_subtree-4 9.530µ 9.586µ ~ 0.400
SubtreeSizes/10k_tx_1024_per_subtree-4 4.727µ 4.735µ ~ 1.000
SubtreeSizes/10k_tx_2k_per_subtree-4 2.358µ 2.349µ ~ 1.000
BlockSizeScaling/10k_tx_64_per_subtree-4 76.03µ 76.13µ ~ 1.000
BlockSizeScaling/10k_tx_256_per_subtree-4 18.77µ 18.97µ ~ 0.200
BlockSizeScaling/10k_tx_1024_per_subtree-4 4.677µ 4.683µ ~ 0.400
BlockSizeScaling/50k_tx_64_per_subtree-4 395.8µ 397.0µ ~ 1.000
BlockSizeScaling/50k_tx_256_per_subtree-4 94.29µ 93.83µ ~ 0.700
BlockSizeScaling/50k_tx_1024_per_subtree-4 23.26µ 23.02µ ~ 0.300
SubtreeAllocations/small_subtrees_exists_check-4 158.1µ 158.8µ ~ 1.000
SubtreeAllocations/small_subtrees_data_fetch-4 164.0µ 163.5µ ~ 0.700
SubtreeAllocations/small_subtrees_full_validation-4 323.9µ 321.3µ ~ 0.200
SubtreeAllocations/medium_subtrees_exists_check-4 9.484µ 9.339µ ~ 0.700
SubtreeAllocations/medium_subtrees_data_fetch-4 9.444µ 9.558µ ~ 0.100
SubtreeAllocations/medium_subtrees_full_validation-4 18.93µ 18.82µ ~ 0.400
SubtreeAllocations/large_subtrees_exists_check-4 2.281µ 2.315µ ~ 0.700
SubtreeAllocations/large_subtrees_data_fetch-4 2.297µ 2.318µ ~ 1.000
SubtreeAllocations/large_subtrees_full_validation-4 4.759µ 4.702µ ~ 0.400
_prepareTxsPerLevel-4 393.3m 397.3m ~ 1.000
_prepareTxsPerLevelOrdered-4 3.580m 3.504m ~ 0.100
_prepareTxsPerLevel_Comparison/Original-4 399.9m 395.6m ~ 0.400
_prepareTxsPerLevel_Comparison/Optimized-4 3.473m 3.587m ~ 0.200
StoreBlock_Sequential/BelowCSVHeight-4 302.8µ 308.8µ ~ 0.100
StoreBlock_Sequential/AboveCSVHeight-4 309.6µ 304.8µ ~ 1.000
GetUtxoHashes-4 254.2n 257.1n ~ 0.700
GetUtxoHashes_ManyOutputs-4 43.89µ 42.15µ ~ 0.100
_NewMetaDataFromBytes-4 233.7n 234.6n ~ 0.300
_Bytes-4 606.4n 626.1n ~ 0.200
_MetaBytes-4 553.5n 550.4n ~ 0.400

Threshold: >10% with p < 0.05 | Generated: 2026-04-09 10:43 UTC

@icellan icellan merged commit b78f034 into main Apr 13, 2026
41 of 44 checks passed
@icellan icellan deleted the fix/reorg-reset-conflict-resolution branch April 13, 2026 17:16
freemans13 added a commit to freemans13/teranode that referenced this pull request Apr 13, 2026
…during reset

After InvalidateBlock, reset() skipped invalid moveBack blocks assuming
BlockValidation's async setTxMinedStatus(unsetMined=true) would handle
them. But that runs asynchronously via a BlockMinedUnset notification
and may not have completed when reset() calls loadUnminedTransactions().

The result: transactions from invalidated blocks retain unmined_since=NULL
(mined state) in the UTXO store, so loadUnminedTransactions() misses them
and they are silently lost from block assembly.

Fix: remove the invalid block skip — MarkTransactionsOnLongestChain is
idempotent, so double-marking is safe and ensures txs are recoverable.

Fixes TestLongestChainPostgres/invalid_block which regressed in PR bsv-blockchain#679.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
oskarszoon added a commit to oskarszoon/teranode that referenced this pull request Apr 14, 2026
…reorg

Two bugs introduced by bsv-blockchain#679 caused TestLongestChainPostgres/invalid_block
to fail deterministically:

1. In reset(), loadUnminedTransactions used CurrentBlock() which still
   pointed to the invalidated block. This included the invalid block's ID
   in bestBlockHeaderIDsMap, causing transactions from that block to be
   incorrectly skipped as "already mined" and marked back as mined on
   longest chain (unmined_since = NULL).

   Fix: update bestBlock atomically before SubtreeProcessor.Reset so
   loadUnminedTransactions sees the correct post-reorg chain.

2. validateUnminedTxInputs requested fields.Inputs but the SQL store
   only populates data.Tx when fields.Tx is explicitly requested. Without
   it, data.Tx was always nil, so every transaction was rejected at the
   nil check — the conflict detection never actually ran.

   Fix: request fields.Tx (to populate data.Tx) and fields.Utxos (to
   force the unbatched store path which properly loads all data).
oskarszoon added a commit to oskarszoon/teranode that referenced this pull request Apr 14, 2026
…reorg

Two bugs introduced by bsv-blockchain#679 caused TestLongestChainPostgres/invalid_block
to fail deterministically:

1. In reset(), loadUnminedTransactions used CurrentBlock() which still
   pointed to the invalidated block. This included the invalid block's ID
   in bestBlockHeaderIDsMap, causing transactions from that block to be
   incorrectly skipped as "already mined" and marked back as mined on
   longest chain (unmined_since = NULL).

   Fix: update bestBlock atomically before SubtreeProcessor.Reset so
   loadUnminedTransactions sees the correct post-reorg chain.

2. validateUnminedTxInputs requested fields.Inputs but the SQL store
   only populates data.Tx when fields.Tx is explicitly requested. Without
   it, data.Tx was always nil, so every transaction was rejected at the
   nil check — the conflict detection never actually ran.

   Fix: request fields.Tx (to populate data.Tx) and fields.Utxos (to
   force the unbatched store path which properly loads all data).
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