fix: correct conflict resolution and assembly recovery during reorg reset#679
Merged
Conversation
…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.
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:
Code Quality:
No issues found - the implementation is correct and ready for merge. |
|
Contributor
Benchmark Comparison ReportBaseline: Current: Summary
All benchmark results (sec/op)
Threshold: >10% with p < 0.05 | Generated: 2026-04-09 10:43 UTC |
ordishs
approved these changes
Apr 9, 2026
oskarszoon
approved these changes
Apr 10, 2026
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>
Merged
3 tasks
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).
This was referenced Apr 14, 2026
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).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Problem
When
handleReorgfalls back toreset()— either because the reorg is too large, contains an invalid block, orsubtreeProcessor.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 byWHERE unmined_since IS NOT NULL. Any assembly transaction whoseunmined_sinceis NULL in the UTXO store — which happens when a competing fork'sBlockValidationprocessed it, inserting it withUnminedSince=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 callsmarkNotOnLongestChain()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()calledloadUnminedTransactionswithvalidateInputs=false. ThegetConflictingNodes()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'sSpendingData. IfSpendingData.TxIDpoints 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 bygetConflictingNodes().Fix
SubtreeProcessor.reset()(services/blockassembly/subtreeprocessor/SubtreeProcessor.go):Before clearing the assembly state, collect all transaction hashes from
chainedSubtreesandcurrentSubtreeand callmarkNotOnLongestChain(). This is idempotent — transactions that already haveunmined_sinceset are unchanged — and directly mirrors whatreorgBlocks()does.BlockAssembler.handleReorg()(services/blockassembly/BlockAssembler.go):Both
reset()call sites inhandleReorgnow passvalidateInputs=true. This enablesvalidateUnminedTxInputs()insideloadUnminedTransactions, which catches any spending conflicts thatgetConflictingNodes()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_sinceto NULL in the UTXO store (simulating a competing fork), callsReset(), and asserts the tx now hasunmined_sinceset. 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
SpendingDatapoints to a different (winning) tx, then callsreset(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=NULLon 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 intest/sequentialtest/double_spend/.Test plan
go test -race -tags testtxmetacache -run TestResetMarksAssemblyTxsAsNotOnLongestChainBeforeClearing ./services/blockassembly/subtreeprocessor/— PASSgo test -race -tags testtxmetacache -run TestReset_ConflictDetectionViaValidateInputs ./services/blockassembly/— PASSmake sequentialtest TEST=TestReorgResetRecoversAssemblyTxPostgres— PASSgo test -race -tags testtxmetacache ./services/blockassembly/...— all existing tests PASS