Skip to content

fix(utxo): roll back ProcessConflicting on step-3+ failure#765

Merged
ordishs merged 2 commits into
bsv-blockchain:mainfrom
ordishs:security/4561-process-conflicting-rollback
May 12, 2026
Merged

fix(utxo): roll back ProcessConflicting on step-3+ failure#765
ordishs merged 2 commits into
bsv-blockchain:mainfrom
ordishs:security/4561-process-conflicting-rollback

Conversation

@ordishs

@ordishs ordishs commented Apr 28, 2026

Copy link
Copy Markdown
Collaborator

Closes #4561.

Summary

ProcessConflicting runs a 5-phase commit to resolve double-spend conflicts during a reorg. Steps 1-2 commit (mark losing txs conflicting + unspend parents + lock them) before step 3 (Spend of winning tx). If step 3 or later fails, no rollback runs — parents stay locked permanently, losing txs stay conflicting permanently, and future reorgs cannot re-apply the transactions. The audit (#4561) classified this as CRITICAL because the failure mode is stuck reorg / permanent UTXO lockout.

Fix

Adds a deferred compensating rollback that fires on any step-2+ failure when at least step 1 committed:

  1. Re-mark the winning conflicting hashes (only if step 4 had committed).
  2. Undo any partial step-3 spends (Unspend the spends whose .Err == nil).
  3. Re-spend losing txs to restore the original parent spending_data (using Spend(losingTx, IgnoreConflicting, IgnoreLocked); the losing tx body is fetched via Get in the rollback path).
  4. SetConflicting(allMarkedHashes, false) to clear the recursive conflict markers from step 1.
  5. SetLocked(parents, false) to undo step 2's lock.

Step 5 (SetLocked(false)) failures are retried with a bounded back-off (3 attempts: 0ms / 50ms / 200ms) before returning. By the time we reach step 5, steps 1-4 are correct; rolling back would re-introduce the very inconsistencies just fixed and create a strictly worse state. The retry path explicitly skips rollback even on exhausted retry — only parents are still locked, which is recoverable manually.

If rollback itself fails, the function returns a wrapped error tagged MANUAL INTERVENTION REQUIRED containing both the original failure and the rollback failure.

Test plan

  • Happy path still passes (no behaviour change on success).
  • Step-3 failure with successful rollback: parents unlocked, losing txs unmarked, original-state restored.
  • Step-3 failure with rollback also failing: returned error mentions both, contains "MANUAL INTERVENTION REQUIRED".
  • Step-5 transient failure: retries succeed on 3rd attempt, overall success.
  • Step-5 persistent failure: 3 attempts exhausted, error surfaced WITHOUT rollback (avoids re-introducing inconsistency).
  • Pre-existing TestProcessConflicting_UnspendError and TestProcessConflicting_SpendError updated to expect the new rollback calls.
  • go test -race ./stores/utxo/... and -tags aerospike variants pass.

@github-actions

github-actions Bot commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

🤖 Claude Code Review

Status: Complete


Current Issues:

The test mocks do not match the actual behavior of MarkConflictingRecursively, which builds allMarkedHashes by accumulating both the initial losing tx hashes AND their descendants from the BFS traversal. Most tests return empty slices for the second return value of SetConflicting, causing allMarkedHashes to be empty. This prevents proper verification that the rollback correctly clears conflicting flags on all recursively-marked transactions.

See inline comments on:

  • stores/utxo/process_conflicting_rollback_test.go (lines 45, 101, 158, 206)
  • stores/utxo/process_conflicting_test.go (lines 183, 239)

Note: The cascade descendants test (TestProcessConflictingRollback_CascadeDescendants) correctly models the BFS behavior and properly verifies rollback with descendants.

Implementation: The rollback logic itself appears sound. The deferred compensating rollback correctly reverses committed phases, the step-5 retry strategy is justified, and partial step-3 spend tracking enables proper cleanup.

// step 1: MarkConflictingRecursively → SetConflicting(losing, true)
affectedSpends := []*Spend{{TxID: &losingTxHash, Vout: 0}}
mockStore.On("SetConflicting", mock.Anything, []chainhash.Hash{losingTxHash}, true).
Return(affectedSpends, []chainhash.Hash{}, nil).Once()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Bug: Test mocks don't match actual MarkConflictingRecursively behavior

The test mocks return empty slices for allMarkedHashes (second return value) from SetConflicting, but in the real implementation, MarkConflictingRecursively builds allMarkedHashes by appending to markedOrder in BFS traversal order.

Impact: The rollback logic at line 299-303 attempts to clear the conflicting flag on allMarkedHashes, but in these tests allMarkedHashes will be empty. This means:

  1. The tests don't verify that rollback step 4 actually clears all recursively-marked hashes
  2. If a losing tx has children that were marked conflicting by MarkConflictingRecursively, the rollback won't clear their flags (in the test scenario)

Fix: Update test mocks to return the same hash in the second return value:

mockStore.On("SetConflicting", mock.Anything, []chainhash.Hash{losingTxHash}, true).
    Return(affectedSpends, []chainhash.Hash{losingTxHash}, nil).Once()

This ensures allMarkedHashes contains at least losingTxHash, matching real-world behavior where the input hashes are always included in markedOrder.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The bot has misread MarkConflictingRecursively. The 2nd return of SetConflicting is the children to enqueue for the next BFS iteration, not the cumulative markedOrder. markedOrder is initialized from the input hashes at process_conflicting.go:386-392 independently of the children return, then appended to per BFS level inside MarkConflictingRecursively.

Returning []chainhash.Hash{} (empty children) correctly terminates the BFS after one iteration with markedOrder = [losingTxHash] — exactly what these tests need. They're exercising the linear case (cascade depth 1), where step-2/3/5 failure paths are the subject and cascade depth is incidental.

The bot's suggested fix — returning []chainhash.Hash{losingTxHash} as the second value — would feed the current iteration's hash back as a next-iteration child, which the BFS guards against with the visited set but at minimum misrepresents the contract (and on a less-guarded variant could loop).

The dedicated BFS-path test is TestProcessConflictingRollback_CascadeDescendants in the same file — that one does return a non-empty second value from SetConflicting to simulate a real cascade. The linear-case tests here intentionally don't, and they're correct as-is.

Return([]chainhash.Hash{losingTxHash}, nil).Once()

affectedSpends := []*Spend{{TxID: &losingTxHash, Vout: 0}}
mockStore.On("SetConflicting", mock.Anything, []chainhash.Hash{losingTxHash}, true).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Same issue: Mock returns empty allMarkedHashes

Same test mock issue as the previous test — SetConflicting returns an empty slice for allMarkedHashes instead of []chainhash.Hash{losingTxHash}. This prevents proper verification of rollback step 4.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same misreading as the bot's first comment at process_conflicting_rollback_test.go:45 — the empty []chainhash.Hash{} is intentional and correctly terminates BFS for the linear-case test. See the reply on comment #3154997257 for the full data-flow explanation. The dedicated TestProcessConflictingRollback_CascadeDescendants is the BFS-fidelity test. No change needed here.

Return([]chainhash.Hash{losingTxHash}, nil).Once()

affectedSpends := []*Spend{{TxID: &losingTxHash, Vout: 0}}
mockStore.On("SetConflicting", mock.Anything, []chainhash.Hash{losingTxHash}, true).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Same issue in retry test

This test also has the empty allMarkedHashes mock problem. Should return []chainhash.Hash{losingTxHash} as the second value.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same misreading as the bot's first comment at process_conflicting_rollback_test.go:45 — the empty []chainhash.Hash{} is intentional and correctly terminates BFS for the linear-case test. See the reply on comment #3154997257 for the full data-flow explanation. The dedicated TestProcessConflictingRollback_CascadeDescendants is the BFS-fidelity test. No change needed here.

Return([]chainhash.Hash{losingTxHash}, nil).Once()

affectedSpends := []*Spend{{TxID: &losingTxHash, Vout: 0}}
mockStore.On("SetConflicting", mock.Anything, []chainhash.Hash{losingTxHash}, true).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Same issue in exhausted retry test

Same mock issue here — should return []chainhash.Hash{losingTxHash} as the second value from SetConflicting.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same misreading as the bot's first comment at process_conflicting_rollback_test.go:45 — the empty []chainhash.Hash{} is intentional and correctly terminates BFS for the linear-case test. See the reply on comment #3154997257 for the full data-flow explanation. The dedicated TestProcessConflictingRollback_CascadeDescendants is the BFS-fidelity test. No change needed here.

Return(errors.NewProcessingError("unspend failed"))

// step 2 failed → rollback only undoes step 1 (clear conflicting flag).
mockStore.On("SetConflicting", mock.Anything, []chainhash.Hash{losingTxHash}, false).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Mock issue in existing test

The SetConflicting mock at line ~176 should return []chainhash.Hash{losingTxHash} as the second value (allMarkedHashes), not an empty slice. This ensures the rollback step 4 (clearing conflicting flags) is properly tested.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same misreading as the bot's first comment at process_conflicting_rollback_test.go:45 — the empty []chainhash.Hash{} is intentional and correctly terminates BFS for the linear-case test. See the reply on comment #3154997257 for the full data-flow explanation. The dedicated TestProcessConflictingRollback_CascadeDescendants is the BFS-fidelity test. No change needed here.

}, nil).Once()
mockStore.On("Spend", mock.Anything, losingTx, mock.Anything, mock.Anything).
Return([]*Spend{}, nil).Once()
mockStore.On("SetConflicting", mock.Anything, []chainhash.Hash{losingTxHash}, false).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Mock issue in SpendError test

The SetConflicting mock at line ~219 should return []chainhash.Hash{losingTxHash} as the second value (allMarkedHashes), not an empty slice.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same misreading as the bot's first comment at process_conflicting_rollback_test.go:45 — the empty []chainhash.Hash{} is intentional and correctly terminates BFS for the linear-case test. See the reply on comment #3154997257 for the full data-flow explanation. The dedicated TestProcessConflictingRollback_CascadeDescendants is the BFS-fidelity test. No change needed here.

@github-actions

github-actions Bot commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

Benchmark Comparison Report

Baseline: main (unknown)

Current: PR-765 (13cd810)

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.664µ 1.691µ ~ 0.100
SplitSyncedParentMap_SetIfNotExists/256_buckets-4 61.58n 61.60n ~ 1.000
SplitSyncedParentMap_SetIfNotExists/16_buckets-4 61.49n 61.51n ~ 0.800
SplitSyncedParentMap_SetIfNotExists/1_bucket-4 61.62n 61.64n ~ 1.000
SplitSyncedParentMap_ConcurrentSetIfNotExists/256_buckets... 29.52n 30.07n ~ 0.200
SplitSyncedParentMap_ConcurrentSetIfNotExists/16_buckets_... 51.66n 51.91n ~ 0.700
SplitSyncedParentMap_ConcurrentSetIfNotExists/1_bucket_pa... 106.0n 105.4n ~ 0.400
MiningCandidate_Stringify_Short-4 266.1n 264.2n ~ 0.100
MiningCandidate_Stringify_Long-4 1.872µ 1.866µ ~ 0.700
MiningSolution_Stringify-4 977.6n 972.3n ~ 0.100
BlockInfo_MarshalJSON-4 1.733µ 1.736µ ~ 0.700
NewFromBytes-4 130.5n 132.7n ~ 0.200
Mine_EasyDifficulty-4 60.81µ 61.07µ ~ 0.700
Mine_WithAddress-4 6.821µ 6.865µ ~ 0.100
DirectSubtreeAdd/4_per_subtree-4 62.07n 59.03n ~ 0.100
DirectSubtreeAdd/64_per_subtree-4 29.36n 31.75n ~ 0.100
DirectSubtreeAdd/256_per_subtree-4 27.21n 30.62n ~ 0.100
DirectSubtreeAdd/1024_per_subtree-4 26.21n 29.27n ~ 0.100
DirectSubtreeAdd/2048_per_subtree-4 25.96n 28.77n ~ 0.100
SubtreeProcessorAdd/4_per_subtree-4 282.7n 284.5n ~ 1.000
SubtreeProcessorAdd/64_per_subtree-4 276.2n 279.8n ~ 0.200
SubtreeProcessorAdd/256_per_subtree-4 276.0n 281.0n ~ 0.100
SubtreeProcessorAdd/1024_per_subtree-4 268.8n 272.1n ~ 0.100
SubtreeProcessorAdd/2048_per_subtree-4 272.6n 270.9n ~ 1.000
SubtreeProcessorRotate/4_per_subtree-4 276.0n 274.4n ~ 0.700
SubtreeProcessorRotate/64_per_subtree-4 274.4n 275.7n ~ 0.700
SubtreeProcessorRotate/256_per_subtree-4 273.1n 277.2n ~ 0.200
SubtreeProcessorRotate/1024_per_subtree-4 277.8n 276.0n ~ 0.700
SubtreeNodeAddOnly/4_per_subtree-4 54.09n 55.09n ~ 0.100
SubtreeNodeAddOnly/64_per_subtree-4 34.37n 34.54n ~ 0.100
SubtreeNodeAddOnly/256_per_subtree-4 33.40n 33.48n ~ 1.000
SubtreeNodeAddOnly/1024_per_subtree-4 32.73n 32.85n ~ 0.200
SubtreeCreationOnly/4_per_subtree-4 113.3n 113.2n ~ 1.000
SubtreeCreationOnly/64_per_subtree-4 404.7n 404.0n ~ 0.700
SubtreeCreationOnly/256_per_subtree-4 1.349µ 1.361µ ~ 0.700
SubtreeCreationOnly/1024_per_subtree-4 4.430µ 4.510µ ~ 0.100
SubtreeCreationOnly/2048_per_subtree-4 8.220µ 8.349µ ~ 0.100
SubtreeProcessorOverheadBreakdown/64_per_subtree-4 275.2n 276.0n ~ 1.000
SubtreeProcessorOverheadBreakdown/1024_per_subtree-4 272.7n 278.0n ~ 0.100
ParallelGetAndSetIfNotExists/1k_nodes-4 823.3µ 816.4µ ~ 0.400
ParallelGetAndSetIfNotExists/10k_nodes-4 1.643m 1.592m ~ 0.100
ParallelGetAndSetIfNotExists/50k_nodes-4 6.952m 6.711m ~ 0.100
ParallelGetAndSetIfNotExists/100k_nodes-4 13.64m 13.38m ~ 0.100
SequentialGetAndSetIfNotExists/1k_nodes-4 666.2µ 658.1µ ~ 0.400
SequentialGetAndSetIfNotExists/10k_nodes-4 2.898m 2.766m ~ 0.100
SequentialGetAndSetIfNotExists/50k_nodes-4 10.80m 10.44m ~ 0.200
SequentialGetAndSetIfNotExists/100k_nodes-4 20.43m 20.02m ~ 0.200
ProcessOwnBlockSubtreeNodesParallel/1k_nodes-4 644.2µ 641.4µ ~ 0.400
ProcessOwnBlockSubtreeNodesParallel/10k_nodes-4 4.298m 4.242m ~ 0.400
ProcessOwnBlockSubtreeNodesParallel/100k_nodes-4 17.06m 16.60m ~ 0.100
ProcessOwnBlockSubtreeNodesSequential/1k_nodes-4 705.3µ 697.2µ ~ 0.100
ProcessOwnBlockSubtreeNodesSequential/10k_nodes-4 6.053m 5.915m ~ 0.100
ProcessOwnBlockSubtreeNodesSequential/100k_nodes-4 41.01m 39.29m ~ 0.100
BlockAssembler_AddTx-4 0.02895n 0.02783n ~ 1.000
AddNode-4 10.71 11.18 ~ 0.700
AddNodeWithMap-4 10.69 10.77 ~ 1.000
DiskTxMap_SetIfNotExists-4 4.198µ 4.132µ ~ 0.400
DiskTxMap_SetIfNotExists_Parallel-4 3.890µ 4.010µ ~ 0.600
DiskTxMap_ExistenceOnly-4 415.7n 404.2n ~ 0.700
Queue-4 205.6n 198.8n ~ 0.100
AtomicPointer-4 3.246n 3.685n ~ 0.100
ReorgOptimizations/DedupFilterPipeline/Old/10K-4 897.5µ 873.3µ ~ 0.400
ReorgOptimizations/DedupFilterPipeline/New/10K-4 853.8µ 882.8µ ~ 0.100
ReorgOptimizations/AllMarkFalse/Old/10K-4 115.4µ 115.0µ ~ 0.700
ReorgOptimizations/AllMarkFalse/New/10K-4 64.69µ 64.33µ ~ 0.400
ReorgOptimizations/HashSlicePool/Old/10K-4 76.03µ 69.89µ ~ 0.200
ReorgOptimizations/HashSlicePool/New/10K-4 11.12µ 11.02µ ~ 0.700
ReorgOptimizations/NodeFlags/Old/10K-4 6.020µ 5.200µ ~ 0.100
ReorgOptimizations/NodeFlags/New/10K-4 2.354µ 1.772µ ~ 0.100
ReorgOptimizations/DedupFilterPipeline/Old/100K-4 11.70m 11.87m ~ 1.000
ReorgOptimizations/DedupFilterPipeline/New/100K-4 12.47m 11.31m ~ 0.100
ReorgOptimizations/AllMarkFalse/Old/100K-4 1.302m 1.157m ~ 0.100
ReorgOptimizations/AllMarkFalse/New/100K-4 708.4µ 706.2µ ~ 0.400
ReorgOptimizations/HashSlicePool/Old/100K-4 592.8µ 588.9µ ~ 0.400
ReorgOptimizations/HashSlicePool/New/100K-4 198.8µ 202.7µ ~ 0.400
ReorgOptimizations/NodeFlags/Old/100K-4 56.40µ 55.12µ ~ 0.100
ReorgOptimizations/NodeFlags/New/100K-4 19.76µ 19.13µ ~ 0.100
TxMapSetIfNotExists-4 46.89n 46.93n ~ 1.000
TxMapSetIfNotExistsDuplicate-4 38.59n 38.69n ~ 1.000
ChannelSendReceive-4 618.1n 617.5n ~ 0.700
CalcBlockWork-4 552.9n 547.1n ~ 0.400
CalculateWork-4 747.2n 753.7n ~ 0.600
BuildBlockLocatorString_Helpers/Size_10-4 1.321µ 1.669µ ~ 0.400
BuildBlockLocatorString_Helpers/Size_100-4 12.19µ 12.40µ ~ 0.700
BuildBlockLocatorString_Helpers/Size_1000-4 120.9µ 121.7µ ~ 0.100
CatchupWithHeaderCache-4 104.1m 104.2m ~ 0.100
_BufferPoolAllocation/16KB-4 3.551µ 3.603µ ~ 0.200
_BufferPoolAllocation/32KB-4 8.864µ 7.885µ ~ 0.100
_BufferPoolAllocation/64KB-4 16.54µ 17.88µ ~ 0.400
_BufferPoolAllocation/128KB-4 29.67µ 30.30µ ~ 0.100
_BufferPoolAllocation/512KB-4 119.8µ 122.2µ ~ 0.700
_BufferPoolConcurrent/32KB-4 20.13µ 19.61µ ~ 0.400
_BufferPoolConcurrent/64KB-4 33.23µ 31.40µ ~ 0.100
_BufferPoolConcurrent/512KB-4 162.8µ 156.7µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/16KB-4 692.0µ 666.0µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/32KB-4 698.9µ 669.7µ ~ 0.200
_SubtreeDeserializationWithBufferSizes/64KB-4 683.9µ 658.5µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/128KB-4 690.3µ 654.7µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/512KB-4 676.3µ 671.5µ ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/16KB-4 36.39m 36.41m ~ 1.000
_SubtreeDataDeserializationWithBufferSizes/32KB-4 36.30m 36.15m ~ 0.400
_SubtreeDataDeserializationWithBufferSizes/64KB-4 36.35m 35.83m ~ 0.700
_SubtreeDataDeserializationWithBufferSizes/128KB-4 36.59m 36.03m ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/512KB-4 36.51m 35.67m ~ 0.100
_PooledVsNonPooled/Pooled-4 742.5n 654.8n ~ 0.700
_PooledVsNonPooled/NonPooled-4 7.334µ 7.084µ ~ 0.200
_MemoryFootprint/Current_512KB_32concurrent-4 7.174µ 7.048µ ~ 0.400
_MemoryFootprint/Proposed_32KB_32concurrent-4 10.82µ 10.46µ ~ 0.200
_MemoryFootprint/Alternative_64KB_32concurrent-4 9.704µ 9.698µ ~ 0.700
_prepareTxsPerLevel-4 406.0m 408.0m ~ 1.000
_prepareTxsPerLevelOrdered-4 3.797m 3.833m ~ 0.700
_prepareTxsPerLevel_Comparison/Original-4 422.5m 420.7m ~ 0.200
_prepareTxsPerLevel_Comparison/Optimized-4 4.353m 3.736m ~ 0.100
SubtreeSizes/10k_tx_4_per_subtree-4 1.423m 1.490m ~ 0.400
SubtreeSizes/10k_tx_16_per_subtree-4 340.1µ 331.5µ ~ 0.700
SubtreeSizes/10k_tx_64_per_subtree-4 82.92µ 81.08µ ~ 0.100
SubtreeSizes/10k_tx_256_per_subtree-4 20.62µ 20.07µ ~ 0.100
SubtreeSizes/10k_tx_512_per_subtree-4 10.153µ 9.857µ ~ 0.100
SubtreeSizes/10k_tx_1024_per_subtree-4 5.038µ 4.977µ ~ 0.400
SubtreeSizes/10k_tx_2k_per_subtree-4 2.503µ 2.475µ ~ 0.700
BlockSizeScaling/10k_tx_64_per_subtree-4 78.83µ 78.00µ ~ 0.400
BlockSizeScaling/10k_tx_256_per_subtree-4 19.98µ 19.90µ ~ 0.400
BlockSizeScaling/10k_tx_1024_per_subtree-4 4.981µ 4.880µ ~ 0.200
BlockSizeScaling/50k_tx_64_per_subtree-4 399.5µ 386.6µ ~ 0.400
BlockSizeScaling/50k_tx_256_per_subtree-4 99.65µ 100.23µ ~ 0.700
BlockSizeScaling/50k_tx_1024_per_subtree-4 24.84µ 24.48µ ~ 0.100
SubtreeAllocations/small_subtrees_exists_check-4 161.8µ 160.6µ ~ 0.100
SubtreeAllocations/small_subtrees_data_fetch-4 171.5µ 170.4µ ~ 0.200
SubtreeAllocations/small_subtrees_full_validation-4 335.1µ 326.4µ ~ 0.100
SubtreeAllocations/medium_subtrees_exists_check-4 9.889µ 9.681µ ~ 0.100
SubtreeAllocations/medium_subtrees_data_fetch-4 10.78µ 10.70µ ~ 0.400
SubtreeAllocations/medium_subtrees_full_validation-4 20.34µ 20.13µ ~ 0.100
SubtreeAllocations/large_subtrees_exists_check-4 2.406µ 2.405µ ~ 0.700
SubtreeAllocations/large_subtrees_data_fetch-4 2.642µ 2.681µ ~ 0.100
SubtreeAllocations/large_subtrees_full_validation-4 5.114µ 5.058µ ~ 0.100
StoreBlock_Sequential/BelowCSVHeight-4 332.9µ 335.2µ ~ 0.700
StoreBlock_Sequential/AboveCSVHeight-4 333.9µ 337.4µ ~ 0.400
GetUtxoHashes-4 256.9n 257.6n ~ 1.000
GetUtxoHashes_ManyOutputs-4 45.97µ 43.08µ ~ 0.100
_NewMetaDataFromBytes-4 238.8n 238.0n ~ 0.700
_Bytes-4 622.0n 617.7n ~ 0.700
_MetaBytes-4 571.7n 565.4n ~ 0.700

Threshold: >10% with p < 0.05 | Generated: 2026-05-12 14:26 UTC

@ordishs ordishs force-pushed the security/4561-process-conflicting-rollback branch from 54d603b to 8a13ebd Compare May 12, 2026 13:32
@sonarqubecloud

Copy link
Copy Markdown

@ordishs ordishs requested review from liam and oskarszoon May 12, 2026 14:41

@oskarszoon oskarszoon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve. Deferred compensating-rollback is correctly shaped and ordered (rollback step 1 re-marks winners conflicting first to avoid an observable inconsistent intermediate state). Step-5 retry-without-rollback decision is sound — rolling back from "winner accepted, parents still locked" to "everything-conflicting" would be strictly worse than retrying SetLocked. Per-spend partial-failure tracking is race-safe + nil-safe. Sonar PASSED 89.9% new-code coverage, benchmark clean.

One ask before prod rollout: please wire alerting on MANUAL INTERVENTION REQUIRED — Prometheus counter + Coralogix/Loki log-pattern rule. Today the tag is grep-able (only 2 hits in process_conflicting.go) but no alert fires, so a real rollback-also-failed event would be silent in prod.

Two small residual exposures worth a follow-up but not gating: (1) rollback step 2 silently errors.Join+continues if a descendant's tx body is unfetchable, leaving a parent UTXO unspent + unlocked + unconflicting — re-locking on rollback failure would be safer; (2) SetConflicting(allMarkedHashes, false) at rollback step 4 could TOCTOU-un-mark a tx a concurrent reorg path independently flagged Conflicting — worth documenting the exclusive-access invariant.

Replied separately to the claude[bot] inline comments — their "[Critical] mocks don't match MarkConflictingRecursively" critique is incorrect; the bot misread the BFS data flow. The second return of SetConflicting is the next-iteration children, not the cumulative markedOrder, so the empty children correctly terminate BFS for the linear-case tests. TestProcessConflictingRollback_CascadeDescendants is the dedicated BFS-fidelity test.

@ordishs ordishs merged commit d6eea7d into bsv-blockchain:main May 12, 2026
38 of 39 checks passed
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