Skip to content

fix(blockassembly): inverse ProcessConflicting in moveBackBlock — pick counter by first-seen CreatedAt#845

Merged
oskarszoon merged 22 commits into
bsv-blockchain:mainfrom
oskarszoon:fix/reorg-process-conflicting-not-reversed
May 14, 2026
Merged

fix(blockassembly): inverse ProcessConflicting in moveBackBlock — pick counter by first-seen CreatedAt#845
oskarszoon merged 22 commits into
bsv-blockchain:mainfrom
oskarszoon:fix/reorg-process-conflicting-not-reversed

Conversation

@oskarszoon

@oskarszoon oskarszoon commented May 12, 2026

Copy link
Copy Markdown
Contributor

Summary

A reorg can leave the UTXO store with parent.SpendingDatas[vout] pointing at the no-longer-canonical tx, the original mempool spender stuck on Conflicting=true, and the now-orphaned spender on Conflicting=false. Block assembly keeps proposing the orphaned spender (and any of its unmined descendants), and SVNode rejects the candidate with bad-txns-inputs-missingorspent. Observed in production on teranode-mainnet-eu-1 (eu-west-3 cluster) on 2026-05-10 — block assembly emitted invalid candidates for hours after a height-948435 reorg.

Root cause: when a block whose subtree carries ConflictingNodes is moved back during a reorg, moveBackBlock collects those hashes into processedConflictingHashesMap (to relax the precondition for the new tip's moveForwards) but never undoes the UTXO-level side effects of the original ProcessConflicting. The chain move succeeds, the UTXO state stays inverted.

Fix

Inverse ProcessConflicting in moveBackBlock, with three additional safeguards: a first-seen CreatedAt heuristic for picking the right counter in multi-fork scenarios, a carry-over filter that skips re-mined txs, and symmetric in-memory eviction so the mining candidate stays consistent with UTXO state.

Core reverse — stores/utxo/process_conflicting.go

A new utxo.ReverseProcessConflicting walks the moved-back block's subtree.ConflictingNodes and, per demoted tx D and per input (parent, vout):

  1. Picks the single counter C from parent.ConflictingChildren with the lowest CreatedAt that

    • is not itself being demoted,
    • is currently Conflicting=true, and
    • actually spends the same (parent, vout).

    CreatedAt is set once at insert in both backends (Aerospike create.go:706, SQL inserted_at default), so the lowest-CreatedAt candidate is the first-seen mempool spender — the canonical spender an earlier ProcessConflicting demoted. Tiebreak on equal CreatedAt is lexicographic hash compare for cross-node determinism. Legacy records with no CreatedAt (value 0) are treated as newer than any timestamped record so the heuristic never picks an unknown-vintage candidate over a known one.

  2. Marks D + descendants Conflicting=true via MarkConflictingRecursively.

  3. Unspends D's input spends.

  4. Spends C's tx, re-pointing parent.SpendingDatas[vout] at C.

  5. Clears Conflicting on C + descendants via the new symmetric UnmarkConflictingRecursively (BFS mirror of MarkConflictingRecursively).

Returns (cascadedToConflicting, allTouched, error) — the cascade drives eviction; allTouched feeds processedConflictingHashesMap so the downstream moveForward recognises both directions of the swap.

Idempotency: a demoted tx already on Conflicting=true short-circuits. CoinbasePlaceholderHashValue short-circuits the same way ProcessConflicting does.

Self-converging on chained reorgs. With the lowest-CreatedAt rule each reverse picks the original first-seen spender independently, so a sequence like reverse([txC]); reverse([txB]) converges to the canonical pre-fork mempool state regardless of which intermediate forks the chain passed through.

Carry-over filter — reorgBlocks

If the same tx appears in BOTH a moveBackBlock's subtree.ConflictingNodes AND a moveForward block (e.g. a conflicting tx that's mined on both branches at the same height), its canonical-spender status carries across the reorg — moveForward handles it via its own ProcessConflicting, and the reverse must not demote it. reorgBlocks pre-computes moveForwardTxSet via collectMoveForwardTxHashes (walks moveForward subtrees) and filters those hashes out of the input to ReverseProcessConflicting.

Symmetric in-memory eviction

moveBackBlockCreateNewSubtrees re-adds the moved-back block's txs to stp.chainedSubtrees BEFORE the reverse runs. By the time the reverse flips them to Conflicting=true, they're already settled in the in-memory subtree, and neither the forward path's dequeueDuringBlockMovement (queue-only) nor processRemainderTxHashes (rebuild via losingTxHashesMap only) touches them.

After ReverseProcessConflicting, reorgBlocks walks cascadedToConflicting and calls stp.Remove(ctx, h) on each — mirrors what BlockAssembler.markAsConflicting already does on the validate-inputs path (BlockAssembler.go:2535). Counter-side (un-cascade) hashes are intentionally NOT evicted; they're now Conflicting=false and remain valid mempool entries.

Subtree-file persist on the reverse cascade

markConflictingTxsInSubtrees is invoked on cascadedToConflicting so the "subtree.ConflictingNodes is the historical superset of every tx ever flagged conflicting" invariant holds. Without it, only the moveForward path would persist (via its own ProcessConflicting → markConflictingTxsInSubtrees), and the filter that short-circuits ProcessConflicting on reverse-cascade hashes would skip that persist too.

Caller-side ProcessConflicting filter

processConflictingTransactions and the reset() moveForward loop filter conflictingNodes against processedConflictingHashesMap before calling ProcessConflicting. Re-running ProcessConflicting on hashes the reverse already swapped fails on the "tx is not conflicting" precondition and double-spends parent UTXOs. When the filter short-circuits, it surfaces the cascade as both losingTxHashesMap (so processRemainderTxHashes drops the losers during chainedSubtrees rebuild) and conflictingSet (so the downstream dequeueDuringBlockMovement filters any queue items).

Plumbing

  • stores/utxo/meta/data.go — new CreatedAt int64 on meta.Data.
  • stores/utxo/aerospike/get.go — populate CreatedAt from the existing createdAt bin when fields.CreatedAt is requested.
  • stores/utxo/sql/sql.go:
    • getUnbatched and batchDecorateChunk select inserted_at and populate CreatedAt via parseInsertedAtMillis.
    • parseInsertedAtMillis handles both Postgres (time.Time) and SQLite (string) return types; returns 0 on unrecognised values so legacy records flow through isOlderCounter's missing-timestamp branch.
  • services/blockassembly/subtreeprocessor/SubtreeProcessor.go:
    • new stp.reverseCascadedConflictingSet field scoped to a single reorgBlocks call.
    • processConflictingTransactions reads it via stp.cloneReverseCascadedSet() when its filter short-circuits.
    • collectMoveForwardTxHashes builds the carry-over filter from moveForward subtree files.

Production state mapping (mainnet incident)

tx role observed Conflicting after fix
b0dfd9df (canonical-chain winner, block 948489) mined main chain true (wrong) false
89ea7be9 (orphaned-block spender, block 948488) unmined after reorg false (wrong) true
4 descendants of 89ea7be9 unmined false (wrong) true (cascade)
7b4f64db.SpendingDatas[1] parent's recorded spender 89ea7be9 (wrong) b0dfd9df

CreatedAt(b0dfd9df) < CreatedAt(89ea7be9) (mempool spender preceded the stale-block spender) — the heuristic picks b0dfd9df unambiguously.

Test plan

  • Unit tests for ReverseProcessConflicting (stores/utxo/reverse_process_conflicting_test.go):
    • happy path (restore original spender + un-cascade)
    • already-reversed skip (idempotent re-run)
    • same-output filter (sibling-output candidate rejected)
    • no-counter-to-promote (D demoted, no Spend/Unmark)
    • already-non-conflicting counter skipped
    • coinbase placeholder short-circuit
    • empty input no-op
    • Get error propagation
    • UnmarkConflictingRecursively BFS cascade
    • multi-counter selection by CreatedAt (oldest wins, younger left conflicting)
    • equal-CreatedAt lex hash tiebreak
    • oldest candidate in demoted-set skipped, next-oldest promoted
    • isOlderCounter helper including legacy CreatedAt=0 branch
  • go test ./stores/utxo/... -count=1 -short — 832 pass across 14 packages.
  • go test ./services/blockassembly/... -count=1 — 375 pass.
  • go test ./test/sequentialtest/... -tags <full>125 pass (postgres + aerospike + sqlite, all double-spend / longest-chain / large-tx-reorg / triple-fork scenarios green).
  • go vet ./stores/utxo/... ./services/blockassembly/... — clean.

Recovery for nodes already in the bad state

Until the fix is deployed:

teranode-cli resetblockassembly --validate-inputs

Marks the unmined side conflicting via validateUnminedTxInputs Case 2 cascade. Does NOT repair parent.SpendingDatas on the winner side; this PR does.

Review history

This PR went through several revisions in response to detailed reviews:

  • v1: post-reorg b.reset(ctx, true) trigger. Reverted — reset replays ProcessConflicting on the new tip's ConflictingNodes, fails precondition on already-promoted txs.
  • v2: inverse ProcessConflicting that promoted every candidate matching the same-output + Conflicting=true filter. Broken on multi-counter scenarios — Spend calls overwrote each other and every counter ended up Conflicting=false.
  • v3: lowest-CreatedAt heuristic for counter selection. Lossless, no new schema, no bestBlockIDsMap plumbing. Integrated.
  • v5: caller-side filter against processedConflictingHashesMap before calling ProcessConflicting (vs changing ProcessConflicting's precondition). Integrated.
  • v6: split reverse return into (cascadedToConflicting, allTouched); symmetric eviction via stp.Remove; carry-over filter for txs re-mined in moveForward; subtree-file persist on cascade. Integrated.

Follow-up issues to file

  • Apply the same (parent, vout) same-output check inside validateUnminedTxInputs Case 2 (BlockAssembler.go:2579) so --validate-inputs recovery doesn't cascade-mark unrelated sibling-output spenders.
  • Investigate the SubtreeProcessor.reset vs GetRemoveMapLength data race surfaced in CI on an earlier PR revision.

…essConflicting effects

Reorg can leave the UTXO store with a Conflicting flag set on a tx that
SHOULD be the canonical chain spender, and cleared on a tx that is no
longer on the chain. Block assembly then keeps proposing the
no-longer-canonical tx (and any of its unmined descendants) and SVNode
rejects the candidate with bad-txns-inputs-missingorspent.

Sequence observed on teranode-mainnet-eu-1 (2026-05-10):

  1. Stale block N_stale arrives first with TX_L, a tx that double-spends
     an existing mempool tx TX_W. Validator stores TX_L with
     Conflicting=true; subtree validation persists TX_L in the subtree
     file's ConflictingNodes list.
  2. moveForwardBlock(N_stale) reads ConflictingNodes from the subtree and
     calls ProcessConflicting([TX_L]). GetCounterConflicting includes the
     input txHash itself in its result set (process_conflicting.go:470),
     so MarkConflictingRecursively flips both TX_L and TX_W (and TX_W's
     descendants) to Conflicting=true. Phase 2 un-spends them all, phase 3
     re-spends as TX_L, phase 4 clears TX_L.Conflicting. Net result:
     TX_W=true, TX_L=false, parent.SpendingDatas[v]=TX_L.
  3. Reorg fires: moveBackBlock(N_stale) + moveForwardBlock(N_winner) where
     N_winner contains TX_W. moveBackBlock does NOT reverse the
     ProcessConflicting side effects. N_winner's subtree was produced
     upstream before the conflict materialized so its ConflictingNodes is
     empty — moveForward's ProcessConflicting branch is skipped. UTXO
     state stays inverted from step 2.

Fix: after a successful Reorg, scan the moveBackBlocks' subtree files for
non-empty ConflictingNodes. If any are found, trigger
b.reset(ctx, true) — the same input-validation reset already used on the
Reorg-failure and large-reorg paths. validateUnminedTxInputs Case 2
(BlockAssembler.go:2499) catches the inversion via
parent.ConflictingChildren containing a counter that's confirmed on the
current best chain, then markAsConflicting cascades the flag to the
unmined descendants. Subsequent mining candidates exclude the affected
txs and SVNode accepts the block.

The detection helper (hasConflictingNodesInBlocks) is deliberately
over-eager: missing a single bad reorg leaves block assembly producing
rejected candidates indefinitely, while a spurious validation reset only
reloads unmined transactions.

Recovery for nodes already in the bad state:
  teranode-cli resetblockassembly --validate-inputs

The stale comment claiming "validateInputs after reorg is redundant" is
removed — reorgBlocks demonstrably misses conflicts when the winner-side
subtree was validated before the local UTXO conflict was detected.
Comment cited validateUnminedTxInputs at BlockAssembler.go:2459, but the
function moved to 2539 (Case 2 at 2579) since the original write-up.
File:line refs in same-file comments rot — function name + 'Case 2' is
enough for a reader to locate it via grep.
@github-actions

github-actions Bot commented May 12, 2026

Copy link
Copy Markdown
Contributor

Benchmark Comparison Report

Baseline: main (unknown)

Current: PR-845 (f693af3)

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.774µ 1.676µ ~ 0.400
SplitSyncedParentMap_SetIfNotExists/256_buckets-4 61.72n 61.69n ~ 1.000
SplitSyncedParentMap_SetIfNotExists/16_buckets-4 61.74n 61.71n ~ 1.000
SplitSyncedParentMap_SetIfNotExists/1_bucket-4 61.65n 61.53n ~ 0.800
SplitSyncedParentMap_ConcurrentSetIfNotExists/256_buckets... 30.28n 31.50n ~ 0.100
SplitSyncedParentMap_ConcurrentSetIfNotExists/16_buckets_... 52.56n 52.10n ~ 1.000
SplitSyncedParentMap_ConcurrentSetIfNotExists/1_bucket_pa... 107.2n 108.7n ~ 0.700
MiningCandidate_Stringify_Short-4 264.6n 263.5n ~ 0.700
MiningCandidate_Stringify_Long-4 1.904µ 1.892µ ~ 0.100
MiningSolution_Stringify-4 978.4n 986.6n ~ 0.100
BlockInfo_MarshalJSON-4 1.760µ 1.754µ ~ 0.200
NewFromBytes-4 111.60n 99.86n ~ 0.700
Mine_EasyDifficulty-4 66.05µ 65.61µ ~ 0.400
Mine_WithAddress-4 7.242µ 7.296µ ~ 1.000
BlockAssembler_AddTx-4 0.02722n 0.02973n ~ 0.200
AddNode-4 11.13 11.01 ~ 0.400
AddNodeWithMap-4 11.25 11.01 ~ 0.700
DirectSubtreeAdd/4_per_subtree-4 59.97n 58.15n ~ 0.700
DirectSubtreeAdd/64_per_subtree-4 31.34n 28.68n ~ 0.100
DirectSubtreeAdd/256_per_subtree-4 30.33n 27.27n ~ 0.100
DirectSubtreeAdd/1024_per_subtree-4 28.72n 26.26n ~ 0.100
DirectSubtreeAdd/2048_per_subtree-4 27.84n 25.81n ~ 0.100
SubtreeProcessorAdd/4_per_subtree-4 282.4n 275.6n ~ 0.200
SubtreeProcessorAdd/64_per_subtree-4 273.9n 273.4n ~ 0.700
SubtreeProcessorAdd/256_per_subtree-4 279.6n 273.7n ~ 0.100
SubtreeProcessorAdd/1024_per_subtree-4 269.7n 265.5n ~ 0.100
SubtreeProcessorAdd/2048_per_subtree-4 269.5n 265.3n ~ 0.100
SubtreeProcessorRotate/4_per_subtree-4 272.4n 272.6n ~ 1.000
SubtreeProcessorRotate/64_per_subtree-4 273.7n 270.2n ~ 0.200
SubtreeProcessorRotate/256_per_subtree-4 272.2n 269.7n ~ 0.100
SubtreeProcessorRotate/1024_per_subtree-4 271.1n 269.5n ~ 0.100
SubtreeNodeAddOnly/4_per_subtree-4 54.50n 53.95n ~ 0.300
SubtreeNodeAddOnly/64_per_subtree-4 34.51n 34.41n ~ 0.400
SubtreeNodeAddOnly/256_per_subtree-4 33.44n 33.22n ~ 0.100
SubtreeNodeAddOnly/1024_per_subtree-4 32.66n 32.78n ~ 0.400
SubtreeCreationOnly/4_per_subtree-4 122.7n 111.9n ~ 0.200
SubtreeCreationOnly/64_per_subtree-4 397.6n 386.2n ~ 0.200
SubtreeCreationOnly/256_per_subtree-4 1.317µ 1.425µ ~ 0.100
SubtreeCreationOnly/1024_per_subtree-4 4.348µ 4.617µ ~ 0.700
SubtreeCreationOnly/2048_per_subtree-4 7.902µ 8.031µ ~ 0.100
SubtreeProcessorOverheadBreakdown/64_per_subtree-4 269.4n 272.8n ~ 0.100
SubtreeProcessorOverheadBreakdown/1024_per_subtree-4 270.4n 271.6n ~ 0.400
ParallelGetAndSetIfNotExists/1k_nodes-4 811.4µ 828.7µ ~ 0.100
ParallelGetAndSetIfNotExists/10k_nodes-4 1.390m 1.615m ~ 0.100
ParallelGetAndSetIfNotExists/50k_nodes-4 6.750m 6.792m ~ 0.700
ParallelGetAndSetIfNotExists/100k_nodes-4 13.54m 13.40m ~ 0.100
SequentialGetAndSetIfNotExists/1k_nodes-4 676.8µ 692.8µ ~ 0.200
SequentialGetAndSetIfNotExists/10k_nodes-4 2.957m 2.764m ~ 0.100
SequentialGetAndSetIfNotExists/50k_nodes-4 10.48m 10.55m ~ 0.700
SequentialGetAndSetIfNotExists/100k_nodes-4 19.95m 19.87m ~ 0.700
ProcessOwnBlockSubtreeNodesParallel/1k_nodes-4 643.1µ 652.6µ ~ 0.100
ProcessOwnBlockSubtreeNodesParallel/10k_nodes-4 4.235m 4.247m ~ 0.400
ProcessOwnBlockSubtreeNodesParallel/100k_nodes-4 16.43m 16.54m ~ 0.100
ProcessOwnBlockSubtreeNodesSequential/1k_nodes-4 699.9µ 695.2µ ~ 1.000
ProcessOwnBlockSubtreeNodesSequential/10k_nodes-4 6.080m 5.975m ~ 0.400
ProcessOwnBlockSubtreeNodesSequential/100k_nodes-4 38.07m 38.22m ~ 0.400
DiskTxMap_SetIfNotExists-4 3.869µ 3.825µ ~ 0.100
DiskTxMap_SetIfNotExists_Parallel-4 3.644µ 3.797µ ~ 0.100
DiskTxMap_ExistenceOnly-4 434.9n 481.0n ~ 0.400
Queue-4 204.8n 205.2n ~ 1.000
AtomicPointer-4 4.381n 4.494n ~ 0.700
ReorgOptimizations/DedupFilterPipeline/Old/10K-4 889.3µ 939.4µ ~ 0.700
ReorgOptimizations/DedupFilterPipeline/New/10K-4 843.7µ 866.0µ ~ 0.200
ReorgOptimizations/AllMarkFalse/Old/10K-4 114.2µ 121.5µ ~ 0.100
ReorgOptimizations/AllMarkFalse/New/10K-4 62.43µ 62.95µ ~ 0.700
ReorgOptimizations/HashSlicePool/Old/10K-4 67.99µ 77.40µ ~ 0.100
ReorgOptimizations/HashSlicePool/New/10K-4 12.10µ 12.13µ ~ 1.000
ReorgOptimizations/NodeFlags/Old/10K-4 5.343µ 5.776µ ~ 0.100
ReorgOptimizations/NodeFlags/New/10K-4 1.880µ 1.904µ ~ 0.100
ReorgOptimizations/DedupFilterPipeline/Old/100K-4 11.94m 11.67m ~ 0.400
ReorgOptimizations/DedupFilterPipeline/New/100K-4 11.49m 11.66m ~ 0.100
ReorgOptimizations/AllMarkFalse/Old/100K-4 1.195m 1.203m ~ 0.100
ReorgOptimizations/AllMarkFalse/New/100K-4 694.7µ 691.0µ ~ 0.400
ReorgOptimizations/HashSlicePool/Old/100K-4 707.8µ 649.6µ ~ 0.700
ReorgOptimizations/HashSlicePool/New/100K-4 326.2µ 350.0µ ~ 0.100
ReorgOptimizations/NodeFlags/Old/100K-4 58.17µ 56.67µ ~ 0.400
ReorgOptimizations/NodeFlags/New/100K-4 19.35µ 20.71µ ~ 0.200
TxMapSetIfNotExists-4 51.66n 51.89n ~ 0.400
TxMapSetIfNotExistsDuplicate-4 37.99n 38.11n ~ 0.100
ChannelSendReceive-4 604.6n 610.1n ~ 0.400
CalcBlockWork-4 496.4n 501.7n ~ 0.100
CalculateWork-4 679.9n 686.1n ~ 0.700
BuildBlockLocatorString_Helpers/Size_10-4 1.325µ 1.332µ ~ 0.400
BuildBlockLocatorString_Helpers/Size_100-4 15.64µ 14.69µ ~ 1.000
BuildBlockLocatorString_Helpers/Size_1000-4 125.3µ 126.2µ ~ 0.700
CatchupWithHeaderCache-4 104.4m 104.4m ~ 0.400
_prepareTxsPerLevel-4 417.7m 413.1m ~ 0.400
_prepareTxsPerLevelOrdered-4 3.748m 3.812m ~ 0.400
_prepareTxsPerLevel_Comparison/Original-4 424.2m 423.2m ~ 1.000
_prepareTxsPerLevel_Comparison/Optimized-4 3.800m 3.721m ~ 0.400
SubtreeSizes/10k_tx_4_per_subtree-4 1.426m 1.400m ~ 1.000
SubtreeSizes/10k_tx_16_per_subtree-4 333.2µ 330.1µ ~ 1.000
SubtreeSizes/10k_tx_64_per_subtree-4 79.30µ 78.16µ ~ 0.400
SubtreeSizes/10k_tx_256_per_subtree-4 19.94µ 19.77µ ~ 0.200
SubtreeSizes/10k_tx_512_per_subtree-4 9.834µ 9.771µ ~ 0.400
SubtreeSizes/10k_tx_1024_per_subtree-4 4.880µ 4.875µ ~ 1.000
SubtreeSizes/10k_tx_2k_per_subtree-4 2.436µ 2.441µ ~ 0.700
BlockSizeScaling/10k_tx_64_per_subtree-4 77.47µ 77.31µ ~ 0.100
BlockSizeScaling/10k_tx_256_per_subtree-4 19.35µ 19.48µ ~ 0.400
BlockSizeScaling/10k_tx_1024_per_subtree-4 4.904µ 4.867µ ~ 0.400
BlockSizeScaling/50k_tx_64_per_subtree-4 408.2µ 403.9µ ~ 1.000
BlockSizeScaling/50k_tx_256_per_subtree-4 97.69µ 97.28µ ~ 0.700
BlockSizeScaling/50k_tx_1024_per_subtree-4 24.11µ 23.75µ ~ 0.400
SubtreeAllocations/small_subtrees_exists_check-4 162.2µ 162.3µ ~ 1.000
SubtreeAllocations/small_subtrees_data_fetch-4 173.2µ 169.6µ ~ 0.100
SubtreeAllocations/small_subtrees_full_validation-4 338.2µ 334.0µ ~ 0.200
SubtreeAllocations/medium_subtrees_exists_check-4 9.714µ 9.676µ ~ 0.100
SubtreeAllocations/medium_subtrees_data_fetch-4 10.22µ 10.08µ ~ 0.100
SubtreeAllocations/medium_subtrees_full_validation-4 19.84µ 19.68µ ~ 0.200
SubtreeAllocations/large_subtrees_exists_check-4 2.353µ 2.331µ ~ 0.800
SubtreeAllocations/large_subtrees_data_fetch-4 2.480µ 2.410µ ~ 0.100
SubtreeAllocations/large_subtrees_full_validation-4 4.938µ 4.877µ ~ 0.100
_BufferPoolAllocation/16KB-4 3.364µ 3.483µ ~ 0.700
_BufferPoolAllocation/32KB-4 8.648µ 8.964µ ~ 1.000
_BufferPoolAllocation/64KB-4 14.54µ 18.04µ ~ 0.100
_BufferPoolAllocation/128KB-4 29.69µ 29.00µ ~ 0.100
_BufferPoolAllocation/512KB-4 114.2µ 113.2µ ~ 1.000
_BufferPoolConcurrent/32KB-4 17.44µ 18.91µ ~ 0.100
_BufferPoolConcurrent/64KB-4 28.36µ 30.75µ ~ 0.100
_BufferPoolConcurrent/512KB-4 152.5µ 151.5µ ~ 0.700
_SubtreeDeserializationWithBufferSizes/16KB-4 652.7µ 652.9µ ~ 1.000
_SubtreeDeserializationWithBufferSizes/32KB-4 651.7µ 660.2µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/64KB-4 644.8µ 654.6µ ~ 0.700
_SubtreeDeserializationWithBufferSizes/128KB-4 637.0µ 663.2µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/512KB-4 633.3µ 663.5µ ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/16KB-4 36.64m 36.26m ~ 1.000
_SubtreeDataDeserializationWithBufferSizes/32KB-4 35.79m 36.24m ~ 0.400
_SubtreeDataDeserializationWithBufferSizes/64KB-4 36.17m 36.14m ~ 1.000
_SubtreeDataDeserializationWithBufferSizes/128KB-4 35.82m 35.60m ~ 0.400
_SubtreeDataDeserializationWithBufferSizes/512KB-4 35.67m 35.40m ~ 0.200
_PooledVsNonPooled/Pooled-4 834.6n 833.7n ~ 0.800
_PooledVsNonPooled/NonPooled-4 7.506µ 6.979µ ~ 0.100
_MemoryFootprint/Current_512KB_32concurrent-4 8.057µ 7.723µ ~ 0.100
_MemoryFootprint/Proposed_32KB_32concurrent-4 11.23µ 11.27µ ~ 1.000
_MemoryFootprint/Alternative_64KB_32concurrent-4 10.66µ 11.52µ ~ 0.100
StoreBlock_Sequential/BelowCSVHeight-4 333.0µ 336.1µ ~ 0.400
StoreBlock_Sequential/AboveCSVHeight-4 330.0µ 332.6µ ~ 1.000
GetUtxoHashes-4 258.4n 261.0n ~ 0.700
GetUtxoHashes_ManyOutputs-4 46.56µ 48.38µ ~ 0.400
_NewMetaDataFromBytes-4 241.2n 242.8n ~ 0.400
_Bytes-4 649.0n 648.8n ~ 1.000
_MetaBytes-4 582.5n 587.1n ~ 0.700

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

…ge cases

Coverage gap on PR 845 was the new branch inside handleReorg — exercising
it requires standing up a full BA with blockchain client / subtree
processor / utxo store, which the existing setupBlockAssemblyTest helper
doesn't reach into for a single-decision case.

Extract the conditional reset call into repairConflictStateAfterReorg
and unit-test it directly with a mocked blockchain client. Covers:

- No-op when moveBackBlocks carry no ConflictingNodes (no blockchain
  call, no log).
- Reset-error path: b.reset() errors at its first GetBestBlockHeader call,
  the function logs Errorf and returns — does not propagate the error
  upward (chain move already succeeded).

Also extend hasConflictingNodesInBlocks tests for two more skip-paths:

- nil subtreeHash entry inside block.Subtrees.
- Corrupt subtree blob on disk (truncated file magic only, deserialize
  fails) — both with and without a healthy fallback subtree in the
  same block list.

11 → 13 sub-tests, no regression in the existing 383 BA tests.
…or the fix

The post-reorg b.reset(ctx, true) path was the wrong cure: when the new
chain's moveForwardBlocks carry their own ConflictingNodes, reset()
replays moveForward and ProcessConflicting precondition errors out
("tx is not conflicting") because the input txs were already promoted
on the original moveForward. This breaks every reorg whose new tip
carries ConflictingNodes, which includes the
TestComplexForkGrandparentConflict and TestLongestChainFork/with
double spend transaction sequential tests.

The correct fix is in moveBackBlock: inverse the side effects of the
original ProcessConflicting call so reverting a block whose subtree
listed ConflictingNodes also reverts the matching UTXO swap. Follow-up
commits add ReverseProcessConflicting and wire it into moveBackBlock.

Drops:
- repairConflictStateAfterReorg and hasConflictingNodesInBlocks
  helpers on BlockAssembler
- reorg_conflicting_nodes_test.go covering those helpers

Restores the deleted "Only validate inputs when the Reorg itself
failed …" comment on the failing-reorg reset branch.
When a block whose subtree carried a non-empty ConflictingNodes list is
moved back during a reorg, the UTXO store has stale state left by the
original moveForward's ProcessConflicting call:

- parent.SpendingDatas[vout] points at the demoted tx (the block's
  promoted winner), not the original mempool spender
- the original counter is still flagged Conflicting=true even though
  it's the canonical spender once the block is gone
- the demoted tx is flagged Conflicting=false even though its
  containing block has been removed from the chain

moveBackBlock previously only collected those conflicting hashes into
processedConflictingHashesMap (to relax the precondition for downstream
moveForward calls). The UTXO state was never actually reverted, so a
later mining candidate would keep including the demoted tx and SVNode
would reject it with bad-txns-inputs-missingorspent — the production
incident on teranode-mainnet-eu-1 on 2026-05-10.

ReverseProcessConflicting walks the demoted hashes; per input it:

  1. Picks the counter from parent.ConflictingChildren that
     (a) is not itself being demoted,
     (b) is currently Conflicting=true (i.e. the original mempool
         spender that was flipped to loser), and
     (c) spends the same (parent, vout) as the demoted tx — guards
         against false-positives where ConflictingChildren contains
         sibling-output spenders that don't actually conflict.
  2. Marks the demoted tx + descendants Conflicting=true via
     MarkConflictingRecursively.
  3. Unspends the demoted tx's input spends.
  4. Spends the counter's tx (re-pointing parent.SpendingDatas[vout]
     at the counter).
  5. Clears Conflicting on the counter + descendants via the new
     UnmarkConflictingRecursively (BFS mirror of
     MarkConflictingRecursively).

Already-reversed demoted txs (Conflicting=true on entry) are skipped
for idempotency. CoinbasePlaceholderHashValue is short-circuited the
same way ProcessConflicting handles it.

reorgBlocks calls ReverseProcessConflicting inside the moveBack loop
right after moveBackBlock returns its conflictingHashes — so reverse
runs *before* the subsequent moveForward passes can re-apply
ProcessConflicting on the new tip's blocks. The
processedConflictingHashesMap population is preserved.

Tests:
- stores/utxo/reverse_process_conflicting_test.go covers happy path,
  idempotent re-run, same-output filter, missing-counter, already-non-
  conflicting counter, coinbase placeholder, empty input, get-error
  propagation, and the un-cascade BFS.
- Existing sequential tests TestComplexForkGrandparentConflict* and
  TestLongestChainFork/with_double_spend_transaction* now green again
  on aerospike and postgres after the prior reset-trigger approach
  was reverted.
@oskarszoon oskarszoon changed the title fix(blockassembly): repair UTXO conflict state after reorg drops ProcessConflicting effects fix(blockassembly): inverse ProcessConflicting in moveBackBlock to restore UTXO state on reorg May 12, 2026
…CreatedAt

The v2 reverse promoted every entry in parent.ConflictingChildren that
passed the same-output + Conflicting=true filter, then called
Spend()/UnmarkConflictingRecursively on all of them. Only one tx can be
the canonical spender of (parent, vout), so successive Spend calls
overwrote each other's SpendingDatas while every candidate ended up
Conflicting=false. In triple-fork / multi-counter scenarios the cascade
flipped txs that should remain conflicting, and the subsequent
moveForward's ProcessConflicting failed precondition. CI surfaced this
across 7+ sequential tests on the v2 push.

Per v3 review: the first-seen mempool spender is the canonical
"previous spender" hint the reverse was guessing at. CreatedAt is set
once at insert (Aerospike create.go:706, SQL inserted_at default), so
the lowest-CreatedAt entry in parent.ConflictingChildren is exactly the
tx an earlier ProcessConflicting demoted. Tiebreak on equal CreatedAt
is lexicographic hash compare for cross-node determinism.

Self-converging in chained reorgs: each reverse independently picks the
oldest, so a sequence like reverse([txC]); reverse([txB]) converges to
the canonical pre-fork mempool state. Subsequent reverses on already-
Conflicting=true demoted txs hit the existing idempotency guard and
no-op.

Plumbing:

- stores/utxo/meta/data.go: new CreatedAt int64 field, json
  "createdAt,omitempty".
- stores/utxo/aerospike/get.go: populate Data.CreatedAt from the
  existing createdAt bin when fields.CreatedAt is requested.
- stores/utxo/sql/sql.go:
  - getUnbatched + batchDecorateChunk: SELECT inserted_at; populate
    Data.CreatedAt via new parseInsertedAtMillis helper which handles
    both Postgres (time.Time) and SQLite (string) return types.
  - parseInsertedAtMillis itself: time.Time / []byte / string layouts,
    returns 0 on unrecognised/legacy/nil so callers can fall through
    to the isOlderCounter missing-timestamp branch.
- stores/utxo/process_conflicting.go:
  - selectCountersForDemotedTx picks the single lowest-CreatedAt
    candidate per input via the new isOlderCounter helper, deduping
    across inputs.
  - candidateMeta Get also asks for fields.CreatedAt now.

Cascade-skip in moveForward / reset paths
(services/blockassembly/subtreeprocessor/SubtreeProcessor.go):

- reorgBlocks adds ReverseProcessConflicting's full touched-hashes
  return to processedConflictingHashesMap so downstream moveForward
  sees them as already-handled.
- Both processConflictingTransactions and the reset() moveForward loop
  filter conflictingNodes against the map before calling
  ProcessConflicting — re-running ProcessConflicting on hashes already
  swapped by the reverse double-spends parent UTXOs and fails. The
  filter makes the swap idempotent across both directions of a
  multi-fork reorg.

Tests added (stores/utxo/reverse_process_conflicting_test.go):

- TestReverseProcessConflicting_PicksOldestCounterByCreatedAt: two
  candidates pass the same-output + Conflicting=true filter, oldest
  wins, younger is left untouched.
- TestReverseProcessConflicting_TiebreakOnEqualCreatedAtByHash: equal
  millis, lex-lower hash wins; pins determinism.
- TestReverseProcessConflicting_OldestCandidateInDemotedSetSkipped:
  demoted-set filter runs before min selection.
- TestIsOlderCounter_OrdersByTimestampThenHash: spot-check on the
  comparison helper, including the CreatedAt=0 legacy-record branch.
@oskarszoon oskarszoon changed the title fix(blockassembly): inverse ProcessConflicting in moveBackBlock to restore UTXO state on reorg fix(blockassembly): inverse ProcessConflicting in moveBackBlock — pick counter by first-seen CreatedAt May 12, 2026
…ry-over

After ReverseProcessConflicting demotes a tx (and its descendants), three
things still left the in-memory mining candidate inconsistent with the
new UTXO state:

1. moveBackBlockCreateNewSubtrees re-adds the moved-back block's txs to
   stp.chainedSubtrees BEFORE we run the reverse, so by the time the
   reverse flips them to Conflicting=true they're already settled in the
   in-memory subtree. The forward path's dequeueDuringBlockMovement only
   filters the queue / rebuilds via losingTxHashesMap — neither touches
   a tx that's already in chainedSubtrees.

   Mirror what BlockAssembler.markAsConflicting does on the
   validate-inputs path: after ReverseProcessConflicting returns the
   cascadedConflicting set, walk it and call stp.Remove on each hash.
   Counter-side (un-cascade) hashes are not evicted — they're now
   Conflicting=false and remain valid mempool entries.

2. ReverseProcessConflicting now returns (cascadedToConflicting,
   allTouched, error). cascadedToConflicting drives the in-memory
   eviction above; allTouched feeds processedConflictingHashesMap so the
   downstream moveForward's filter recognises both directions of the
   swap.

3. If the same tx is in BOTH a moveBackBlock's subtree.ConflictingNodes
   AND a moveForward block (e.g. testConflictingTxReorg's tx1Conflicting
   appears in 103a AND 103b), its canonical-spender status carries
   across the reorg — moveForward will handle it via its own
   ProcessConflicting and the reverse must not demote it. reorgBlocks
   now pre-computes moveForwardTxSet via collectMoveForwardTxHashes
   (walks moveForwardBlocks' subtree files) and filters those hashes
   out of the input to ReverseProcessConflicting.

Symmetric subtree-file persist:

- markConflictingTxsInSubtrees is also invoked on the reverse-cascade
  set so the "subtree.ConflictingNodes is the historical superset of
  every tx ever flagged conflicting" invariant holds — without it,
  only the moveForward path would persist, and the filter that
  short-circuits ProcessConflicting on reverse-cascade hashes would
  skip that persist too.

Caller-side ProcessConflicting filter:

- processConflictingTransactions and the reset() moveForward loop
  filter conflictingNodes against processedConflictingHashesMap before
  calling ProcessConflicting. Re-running ProcessConflicting on hashes
  the reverse already swapped fails on the "tx is not conflicting"
  precondition and double-spends parent UTXOs. The filter short-
  circuits gracefully; when it skips, it surfaces the cascade as
  both losingTxHashesMap (so processRemainderTxHashes drops the
  losers during chainedSubtrees rebuild) and conflictingSet (so the
  downstream dequeueDuringBlockMovement filters any queue items).

SubtreeProcessor state:

- New stp.reverseCascadedConflictingSet field scoped to a single
  reorgBlocks call. processConflictingTransactions reads it via
  stp.cloneReverseCascadedSet() when its filter short-circuits.
  Reset to nil on reorg exit.

Test plumbing:

- Updated all ReverseProcessConflicting call sites in tests for the
  new (cascadedToConflicting, allTouched, error) signature.

Local verification:
- go test ./services/blockassembly/... -count=1: 375 pass.
- go test ./stores/utxo/... -count=1 -short: 832 pass across 14 pkgs.
- go test ./test/sequentialtest/... -tags <full>: 125 pass.
…onflicting-not-reversed

# Conflicts:
#	stores/utxo/sql/sql.go
…s + cloneReverseCascadedSet

Sonar new-code coverage was 58.4% — the new reorg-reverse helpers were
exercised by the sequential integration suite (postgres + aerospike)
but not by unit tests, so sonar's coverage upload didn't see them.

Add focused unit tests:

- TestCollectMoveForwardTxHashes_BuildsTxSet (8 sub-tests): nil block
  list, no-subtrees block, nil block entry, nil subtree-hash entry,
  coinbase-placeholder exclusion, multi-block dedup, FileTypeSubtree
  → FileTypeSubtreeToCheck fallback, missing-subtree error, corrupt-
  blob error. Pins both the happy path and the failure-mode contract
  (errors surface, no silent skip).

- TestCloneReverseCascadedSet_RoundTrips (1 test): nil outside an
  active reorg, populated copy independent of processor state,
  re-nil after reorg exit. Guards the in-place mutation
  dequeueDuringBlockMovement performs on the returned set.

buildSubtreeWithTxs + mustSerialize helpers are local to the file.
plainBlob wrapper isolates the test surface from blob.Store directly
so future tests can drop in a different backend without touching
this file's signatures.

All 203 subtreeprocessor tests pass.
@oskarszoon oskarszoon self-assigned this May 12, 2026
… in reorg_reverse_helpers_test.go

depguard rejects stdlib `errors` — must use teranode `errors` package.
The import was carrying a placeholder `closingReader` helper that was
unused anyway; both removed. Also collapse the import block into a
single group to satisfy gci default ordering (the rest of the package
files don't separate teranode imports from third-party).

CI lint now clean; prealloc warnings in SubtreeProcessor_test.go are
pre-existing and unrelated.
… in processConflictingTransactions

When the moveBack reverse already promoted a counter and demoted the
matching ConflictingNodes hash, processConflictingTransactions filters
those hashes out before calling ProcessConflicting. The short-circuit
must still surface the reverse cascade as both losingTxHashesMap and
conflictingSet so the chainedSubtrees rebuild and the next
dequeueDuringBlockMovement evict the now-conflicting txs.

Three sub-tests:
- all conflicting nodes pre-processed + non-empty reverse cascade -> both returned
- all pre-processed + nil reverse cascade -> both nil
- empty input short-circuits without touching reverse cascade

MockUtxostore with no ProcessConflicting expectation proves the filter
skips the call. Could not extend this to a full sqlitememory reorg
integration test: SetConflicting holds a sql.Tx and calls GetSpend on
the same pool, which deadlocks on sqlite shared-cache (see comment in
stores/utxo/tests/tests.go:670-676). The reorgBlocks reverse path is
covered end-to-end by the 125 sequential integration tests (postgres
+ aerospike + sqlite).
…eInsertedAtMillis

reverse_process_conflicting_test.go (+9 sub-tests):
- DemotedMetaNilSkipsTx — pruned-record short-circuit
- SelectCountersErrorPropagates — parent.ConflictingChildren lookup failure
- MarkConflictingErrorPropagates — SetConflicting failure on demoted-side cascade
- UnspendErrorPropagates — half-reversed-state guard
- CounterMetaNilSkipsCounter — pruned counter mid-reverse
- CounterSpendErrorPropagates — counter-promotion Spend failure
- CounterUnmarkErrorPropagates — Unmark cascade failure
- SelectCountersForDemotedTx_ParentMetaNilSkips — nil parent meta
- SelectCountersForDemotedTx_CandidateGetErrorPropagates / CandidateMetaNilSkipsCandidate

Coverage on stores/utxo/process_conflicting.go:
- ReverseProcessConflicting 83.9% -> 96.4%
- selectCountersForDemotedTx 85.3% -> 97.1%
- UnmarkConflictingRecursively 95.2% -> 100%

parse_inserted_at_test.go (new file, 9 sub-tests):
pure-function tests for parseInsertedAtMillis: nil, time.Time, SQLite default
layout, fractional seconds, RFC3339Nano, RFC3339, []byte, unparseable string,
unrecognised type. Pins the per-type branch behaviour so a driver-shape change
can't silently bypass the CreatedAt heuristic.
Four sub-tests:
- single input -> single Spend record with correct TxID/Vout/SpendingData
- multiple inputs -> records in input order with Vin matching input index
- nil PreviousTxScript -> UTXOHashFromInput error propagates, nil slice
- empty input slice -> empty Spends slice

The error path matters: ReverseProcessConflicting uses spendsForTx on
the demoted tx before un-spending its inputs. Silently dropping a bad
input would leave parent.SpendingDatas[vout] pointing at the demoted
tx — the exact failure mode this PR fixes for the canonical-spender
swap.
@oskarszoon oskarszoon requested review from freemans13 and ordishs May 12, 2026 19:03
…onflicting-not-reversed

# Conflicts:
#	stores/utxo/process_conflicting.go
…t-reversed' into fix/reorg-process-conflicting-not-reversed
…sharpen Remove-failure comment

Two review nits from Simon on reorgBlocks reverse-cascade eviction:

1. The 'h := hash' alias was redundant — chainhash.Hash is a value type,
   Remove takes a value not a pointer, no goroutine closes over h. Drop
   it. Now matches the peer site at BlockAssembler.go:2551.

2. Comment now explains why Warnf-and-continue is correct on Remove
   failure: the UTXO mutations are already committed and can't be
   rolled back from here; the realistic failure mode (race with
   concurrent eviction) lands in the desired end state; genuine
   corruption is recoverable via next reorg/reset or --validate-inputs.
   Failing loudly would not improve consistency.
@ordishs

ordishs commented May 13, 2026

Copy link
Copy Markdown
Collaborator

Pre-approval requests — two items I'd like clarified or addressed before signing off. Both are about end-state correctness, not the happy path. The fix as it stands resolves the production incident; these are about residual edges.

1. Partial-reverse state on Spend/Unmark failure leaves parent permanently orphaned

Walk-through inside ReverseProcessConflicting:

  1. MarkConflictingRecursively(D) ✓ — D and descendants now Conflicting=true
  2. Unspend(D's inputs) ✓ — parent.SpendingDatas[vout] cleared
  3. Spend(C) ✗ — error propagates, reorg aborts

On any retry of the same reverse, demotedMeta.Conflicting == true at the top of the loop and we short-circuit. Result: parent.SpendingDatas[vout] stays empty (no spender recorded), counter stays Conflicting=true. TestReverseProcessConflicting_CounterSpendErrorPropagates documents that the error surfaces, but not that the recovery path is now blocked by the very precondition that's meant to make this idempotent.

Two options either of which I'd accept:

  • Document in the function godoc + the Conflicting=true short-circuit comment that recovery from this case requires teranode-cli resetblockassembly --validate-inputs, and confirm the validate-inputs path actually repairs parent.SpendingDatas on the winner side (the PR description says it does NOT — that's the gap this PR fills, so we'd be relying on a path that doesn't exist for the failure mode).
  • Reorder so Spend(C) happens before MarkConflictingRecursively(D) + Unspend(D), or guard the short-circuit on observable parent state rather than D.Conflicting alone, so a retry actually completes the reverse.

Either is fine — I just want to know which it is.

2. Counter-tx lifecycle after un-cascade — SubtreeProcessor.go:2902-2904

// Counter-side (unmarked) hashes are intentionally NOT evicted — they're now
// Conflicting=false and remain valid mempool entries.

Counters were never in chainedSubtrees before the reverse (they were Conflicting=true). After the reverse they're Conflicting=false but still not in chainedSubtrees, so the next mining candidate won't propose them. What's the expected path that re-introduces them to the mining flow? If it's "Propagation re-feeds them on next gossip" that works for counters that exist on multiple nodes but not for ones only this node had. If it's "they're effectively lost as mining candidates until manual resubmit" — that's an acceptable trade-off but the "remain valid mempool entries" wording reads like they'd be picked up naturally.

One line on the actual lifecycle in the comment would resolve this.

Otherwise this is in good shape — the cumulative test pyramid (unit → package → sequentialtest across postgres/aerospike/sqlite) is solid, and the CreatedAt heuristic with legacy-as-newer + lex tiebreak is the right shape for cross-node determinism.

Two pre-approval issues from Simon's review of PR bsv-blockchain#845.

1. Partial-reverse state trap (ReverseProcessConflicting).

   The D.Conflicting=true short-circuit at the top of the loop trapped
   partial-reverse state. If a previous call succeeded at step 1 (Mark)
   and step 2 (Unspend) but failed at step 3 (Spend(C)), the next call
   would short-circuit on D.Conflicting=true and parent.SpendingDatas[vout]
   would stay empty — counter still Conflicting=true, parent orphaned.

   Replace the guard with isReverseFullyApplied: checks every input of D
   for parent.SpendingDatas[vout] pointing to a non-nil, non-D spender.
   If any input is nil (post-Unspend gap) or still points at D (Unspend
   never ran), the helper returns false and the loop falls through to
   re-run the steps to completion. The Mark and Unspend calls are
   idempotent at the store layer; the missing Spend(C)+Unmark(C) then
   runs and the partial state is cleared.

   Cost: one extra Get(parent, fields.Utxos) per demoted hash on the
   rare retry path. Happy path unchanged.

2. Counter-tx lifecycle comment (reorgBlocks reverse).

   Old comment claimed counter-side hashes 'remain valid mempool
   entries' which read like they'd flow back into mining naturally.
   They don't: counters were Conflicting=true before the reverse so
   they were never in chainedSubtrees, and the reverse only flips the
   flag — it doesn't stp.Add them. Mining candidate inclusion relies on
   the next validator re-feed (gossip) or operator RPC resubmit. The
   comment now spells this out explicitly, including the rationale for
   not calling stp.Add here (needs tx body in hand, risks adding txs
   whose parents are still missing post-reorg).

Tests in stores/utxo/reverse_process_conflicting_test.go:
- TestReverseProcessConflicting_SkipsAlreadyReversedDemoted updated to
  cover the new fully-applied confirmation Get.
- TestReverseProcessConflicting_PartialStateRetryCompletes — drives
  the step-3-fails-then-retries flow end-to-end through the helper.
- TestReverseProcessConflicting_DConflictingButParentStillPointsToD —
  pathological case where Mark succeeded but Unspend never ran.
- TestIsReverseFullyApplied — unit tests of the predicate covering
  the non-D spender, nil slot, D-spender, short slice, missing parent,
  and store-error paths.
@oskarszoon

Copy link
Copy Markdown
Contributor Author

Both addressed in a176a2994.

1. Partial-reverse trap — went with Option 2 (parent-state guard). The D.Conflicting=true check is now followed by isReverseFullyApplied, which iterates D's inputs and confirms parent.SpendingDatas[vout] is populated with a non-nil, non-D spender for every one. If any input is nil (post-Unspend gap) or still points at D (Unspend never ran), the helper returns false and the loop falls through to re-run. Mark and Unspend are idempotent at the store layer; Spend(C) + Unmark(C) then complete the missed steps.

Skipped Option 1 because the PR description explicitly says --validate-inputs does NOT repair parent.SpendingDatas on the winner side — that's the gap this PR fills — so pointing at it as the recovery path is circular.

Skipped sub-option (reorder Spend(C) before Mark+Unspend) because Spend(C) would fail against a UTXO still spent by D unless we add an IgnoreSpent flag, which is a much wider change.

Cost: one extra Get(parent, fields.Utxos) per demoted hash on the rare retry path. Happy path unchanged. Tests:

  • TestReverseProcessConflicting_PartialStateRetryCompletes — drives the step-3-fails-then-retries flow through the helper.
  • TestReverseProcessConflicting_DConflictingButParentStillPointsToD — pathological case where only Mark succeeded.
  • TestIsReverseFullyApplied — predicate unit tests for non-D / nil / D-spender / short slice / missing parent / store-error paths.

2. Counter-tx lifecycle — comment rewritten to spell out the actual state machine:

  • Counter is Conflicting=false in the UTXO store (visible via RPC / GetSpend) ✓
  • Counter is NOT in chainedSubtrees — it was Conflicting=true before the reverse so it never made it into the in-memory subtree, and the reverse only flips the flag, it doesn't stp.Add the body
  • Mining flow re-inclusion path: validator re-feed from gossip (works when other nodes also held the tx) or operator RPC resubmit
  • For locally-only counters (rare since the original demotion was triggered by a competing mined tx the rest of the network saw too) the counter is shelved until resubmit

Documented the trade-off explicitly: alternative is stp.Add per counter post-reverse, but that needs the tx body in hand and risks adding txs whose parents are still missing post-reorg. Acceptable gap, not silently swept under "valid mempool entry."

499 → 508 tests passing on touched packages. Vet + lint clean.

@bsv-blockchain bsv-blockchain deleted a comment from github-actions Bot May 13, 2026
@bsv-blockchain bsv-blockchain deleted a comment from github-actions Bot May 13, 2026
@bsv-blockchain bsv-blockchain deleted a comment from github-actions Bot May 13, 2026
@github-actions

github-actions Bot commented May 13, 2026

Copy link
Copy Markdown
Contributor

🤖 Claude Code Review

Status: Complete

Analysis Summary

This PR addresses a critical production bug where reorgs left UTXO state inconsistent. The fix introduces ReverseProcessConflicting to undo UTXO-level side effects when blocks are moved back during reorgs. The implementation is comprehensive with extensive tests and thoughtful design.

Findings

No critical issues identified. The implementation demonstrates careful attention to correctness and edge cases. Below are observations and minor suggestions:

Logic & Correctness ✅

Solid fundamentals:

  • The CreatedAt-based heuristic for counter selection is well-reasoned: oldest timestamp wins, with lexicographic hash tiebreak for determinism
  • The carry-over filter correctly skips txs that appear in both moveBack and moveForward blocks
  • Symmetric eviction maintains consistency between UTXO state and in-memory subtrees
  • Idempotency is properly handled via isReverseFullyApplied checking parent.SpendingDatas state

Edge cases covered:

  • Empty input handling
  • Coinbase placeholder short-circuits
  • Legacy records with CreatedAt=0 are treated as newer (safe fallback)
  • Multiple rounds of reorgs converge due to independent counter selection per reverse

Test Coverage ✅

Comprehensive testing:

  • 1074 lines of new unit tests for ReverseProcessConflicting
  • Multi-counter selection by CreatedAt tested
  • Equal CreatedAt tiebreak verified
  • Legacy CreatedAt=0 branch covered
  • Filter tests for reverse-cascade surfacing
  • All existing tests pass (832 in utxo, 375 in blockassembly, 125 in sequentialtest)

Documentation 📝

Strong inline documentation:

  • Function headers clearly explain parameters, returns, and behavior
  • The PR description provides excellent context about the production incident
  • Code comments explain non-obvious decisions (e.g., why CreatedAt=0 is treated as newer)

Minor observation:
The parseInsertedAtMillis function at stores/utxo/sql/sql.go:1627 has good test coverage and handles both Postgres (time.Time) and SQLite (string) gracefully, returning 0 on parse failure which correctly flows into the legacy-record branch.

Recommendation

Approve for merge. The fix is well-designed, thoroughly tested, and addresses the root cause of the production issue. The implementation follows the project's careful-engineer philosophy from AGENTS.md.


Review completed statically per security constraints for fork PRs

@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

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

See analysis details on SonarQube Cloud

@oskarszoon oskarszoon merged commit 215d616 into bsv-blockchain:main May 14, 2026
25 checks passed
oskarszoon added a commit that referenced this pull request May 14, 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