Skip to content

fix(utxo): tolerate a dangling spender reference under a recent parent (#1214) - #1681

Open
ordishs wants to merge 2 commits into
bsv-blockchain:mainfrom
ordishs:fix/counter-conflicting-dangling-tolerance
Open

fix(utxo): tolerate a dangling spender reference under a recent parent (#1214)#1681
ordishs wants to merge 2 commits into
bsv-blockchain:mainfrom
ordishs:fix/counter-conflicting-dangling-tolerance

Conversation

@ordishs

@ordishs ordishs commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

The wedge

SequentialSpendAndCreate is spend-first. It records a transaction's spends on its parents, then creates the transaction's own record:

spends, err = s.Spend(ctx, tx, blockHeight, options.IgnoreFlags)
// ...
md, err := s.Create(ctx, tx, blockHeight, opts...)

A crash in that window, or a rollback that exhausts its three unspendWithRetry attempts, leaves a parent output slot naming a spender the store has no record of — a dangling spender reference.

GetCounterConflictingTxHashes then walks that absent spender. GetConflictingChildren fails on its root read, the error propagates out of checkCounterConflictingOnCurrentChain, and block validation wedges on a block that SVNode-following peers accept.

Create-first (#1355) would close the window at source, but it is still open and unmerged. #1393 bounded and deduped the walk; it did not touch the absent-record case.

The guard

An absent record has no BlockIDs, so it is definitionally not mined on our chain. Tolerance is gated on the parent's confirmation depth:

  • Every parent output slot naming the spender confirmed within the retention window, or unmined → no counter mined on those slots could have been pruned yet, so the absence must be a never-created loser. Drop the spender from the counter set and continue.
  • Any slot below that window → a mined-then-pruned counter cannot be ruled out. Fail closed; SVNode would reject a block double-spending a confirmed output.
  • Parent depth unknown, or retention == 0 → fail closed.

Tolerance is ANDed across every slot naming the spender, not taken from the first seen, so one unprovable slot is enough to reject.

The guard is scoped to ErrTxNotFound / ErrNotFound; every other walk error still propagates. The tip height is read lazily, only when an absent record is hit, so a store carrying no dangling reference is never asked for it.

Tolerated absences increment teranode_utxo_dangling_spender_ref_tolerated_total, so the inconsistency stays visible rather than silent.

Proof

The end-to-end tests build the dangling reference on a real sqlitememory store — WithSpendOnly() with no matching WithCreateOnly() is exactly the spend-first window — then run checkCounterConflictingOnCurrentChain.

With the guard disabled, identical inputs, pre-fix semantics:

--- FAIL: TestCheckCounterConflictingOnCurrentChain_ToleratesDanglingRefUnderRecentParent
--- PASS: TestCheckCounterConflictingOnCurrentChain_FailsClosedOnDanglingRefUnderBuriedParent

With the guard:

--- PASS: TestCheckCounterConflictingOnCurrentChain_ToleratesDanglingRefUnderRecentParent
--- PASS: TestCheckCounterConflictingOnCurrentChain_FailsClosedOnDanglingRefUnderBuriedParent

The fail-closed case passes in both configurations: the guard does not weaken the consensus check, it only narrows what wedges.

Verification

go test ./stores/utxo/... ./services/subtreevalidation/...   14 packages ok, exit 0
go test -race ./stores/utxo/ ./services/subtreevalidation/ ./stores/utxo/sql/...   ok, exit 0
go vet ./...                                                 exit 0
golangci-lint run stores/utxo/... services/subtreevalidation/...   exit 0

The go vet and lint output that remains is pre-existing, in test/utils and older test files this PR does not touch.

Prune horizon — checked

The tolerance is sound only if the store never deletes a transaction mined on the longest chain before mined_height + retention. That assumption is now verified rather than assumed, and stores/utxo/sql/prune_horizon_test.go pins it against the real pruner service.

The DAH arithmetic agrees across both backends: the Aerospike Go expression (set_mined_expressions.go) and the Lua UDF (teranode.lua:988) both compute currentBlockHeight + blockHeightRetention, and SQL's SetMinedMulti uses minedBlockInfo.BlockHeight + retention (sql.go:3346). The pruner deletes on delete_at_height <= tip. The spend path stamps tip + 1 + retention, which is strictly later, so the earliest stamp a mined transaction can carry comes from SetMinedMulti.

Tests, on a real store with the real pruner:

  • mined at 1000, outputs already spent → survives pruning at 1000 + retention - 1
  • same → deleted at 1000 + retention
  • flagged conflicting at 1000 (stamped 1000 + retention), then mined at 1500 → SetMinedMulti bumps the stale stamp forward, and it survives to 1500 + retention - 1

The boundary is exact, with no slack: the guard tolerates while parent_height + retention > tip, and a counter mined at h >= parent_height survives while h + retention > tip.

Residual risk

Aerospike does not bump a stale conflicting stamp. teranode.lua returns early in its conflicting branch (if rec[BIN_CONFLICTING] then ... return "", nil), and the Go expression yields ExpUnknown for a conflicting record that already has a DAH. SQL bumps it forward; Aerospike does not. For that to shorten the horizon a record would have to be conflicting-flagged and mined on the longest chain at once, which is contradictory in the node's own model — conflicting means "we consider this a loser". I could not construct the state (the store refuses to spend a conflicting transaction's outputs with TX_CONFLICTING), but I did not prove it unreachable, and I did not write the Aerospike-side equivalent of these tests.

Unrelated pre-existing bug found while testing. Store.SetConflicting deadlocks on SQLite: it opens a transaction at sql.go:4325, then calls s.GetSpend on the pool at sql.go:4383, which cannot get a connection until that transaction commits. Reproduced as a hang until the 600s test timeout. Every existing sql_test.go call passes an empty hash slice, so no current test reaches it. Not touched by this PR — the horizon test writes the conflicting flag with direct SQL to route around it. Worth its own issue.

bsv-blockchain#1214)

SequentialSpendAndCreate records a transaction's spends on its parents before it
creates the transaction's own record. A crash in that window, or a rollback that
exhausts its retries, leaves a parent output slot naming a spender the store has
no record of. GetCounterConflictingTxHashes then walks that absent spender,
GetConflictingChildren fails on its root read, and the error propagates out of
checkCounterConflictingOnCurrentChain. Block validation wedges on a block that
SVNode-following peers accept.

Gate the tolerance on the parent's confirmation depth. An absent record has no
BlockIDs, so it is not mined on our chain. When every parent output slot naming
the spender is confirmed within the UTXO store retention window, no counter mined
on those slots could have been pruned yet, so the absence must be a never-created
loser: drop the spender from the counter set and continue. Below that window a
mined-then-pruned counter cannot be ruled out, so fail closed — SVNode would
reject a block double-spending a confirmed output. A parent whose depth is
unknown, and retention 0, both fail closed.

The guard is scoped to ErrTxNotFound/ErrNotFound; every other walk error still
propagates. The tip height is read lazily, only when an absent record is hit, so
stores that carry no dangling reference are never asked for it. Tolerated
absences increment teranode_utxo_dangling_spender_ref_tolerated_total, so the
inconsistency stays visible rather than silent.
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

🤖 Claude Code Review

Status: Complete

The guard is carefully reasoned and well-tested (real-store e2e for both tolerate/fail-closed, plus prune-horizon boundary tests on the real pruner). One correctness concern worth verifying, plus existing open threads.

Current Review:

  • [Major] Root-vs-descendant NotFound ambiguity: the tolerance branch keys off the root spender's parent depth, but GetConflictingChildren surfaces ErrTxNotFound for any absent node in the walk, not just the root. If the root spender exists and is mined on our chain while a descendant is the dangling ref, tolerance can silently drop a genuinely-mined counter and let a confirmed double-spend through. Suggest confirming the root spender's own record is absent before tolerating.
  • Existing threads still apply to current code and are left unresolved: the [Minor] BlockIDs-redundancy note (comment overstates it as required), and @freemans13's shelf-life / pruned-parent recurrence observations.

Everything else — the retention arithmetic, the ANDed per-slot tolerance, lazy tip read, metric visibility, and the retention == 0 / unknown-depth fail-closed paths — looks sound. This is advisory; human reviewers make the final call.

parentTxMeta, err := s.Get(ctx, parentTxHash, fields.Utxos)
// fields.BlockIDs is requested alongside fields.BlockHeights because the SQL
// backend only populates BlockHeights when the block-id join is loaded; the
// parent-depth guard below relies on BlockHeights being present for a mined parent.

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.

[Minor] The stated rationale here is slightly imprecise. needsBlockIDsQuery (stores/utxo/sql/sql.go:1784) already returns true when fields.BlockHeights alone is requested, so the SQL backend loads the block_ids join and populates BlockHeights without fields.BlockIDs being explicitly requested. Aerospike likewise populates BlockHeights directly from its own bin (get.go:977). Requesting fields.BlockIDs here is therefore redundant — harmless and arguably good defensive practice, but the comment overstates it as required. Consider rewording to reflect it is belt-and-suspenders rather than necessary.

@sonarqubecloud

sonarqubecloud Bot commented Sep 1, 2026

Copy link
Copy Markdown

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Benchmark Comparison Report

Baseline: main (unknown)

Current: PR-1681 (d2afb94)

Summary

  • Regressions: 0
  • Improvements: 0
  • Unchanged: 139
  • Significance level: p < 0.05
All benchmark results (sec/op)
Benchmark Baseline Current Change p-value
_NewBlockFromBytes-4 1.616µ 1.614µ ~ 0.800
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=1024/memory-4 12.83m 12.88m ~ 0.100
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=1024/disk_1-4 12.88m 12.94m ~ 0.700
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=1024/disk_2-4 12.89m 12.87m ~ 0.700
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=16384/memo... 34.61m 33.56m ~ 0.700
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=16384/disk... 42.28m 44.80m ~ 0.100
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=16384/disk... 43.71m 42.39m ~ 0.700
SplitSyncedParentMap_SetIfNotExists/256_buckets-4 70.89n 70.87n ~ 0.800
SplitSyncedParentMap_SetIfNotExists/16_buckets-4 71.05n 70.91n ~ 0.800
SplitSyncedParentMap_SetIfNotExists/1_bucket-4 70.84n 71.13n ~ 0.200
SplitSyncedParentMap_ConcurrentSetIfNotExists/256_buckets... 36.46n 35.69n ~ 1.000
SplitSyncedParentMap_ConcurrentSetIfNotExists/16_buckets_... 63.34n 55.99n ~ 0.100
SplitSyncedParentMap_ConcurrentSetIfNotExists/1_bucket_pa... 188.5n 133.6n ~ 0.100
MiningCandidate_Stringify_Short-4 195.2n 187.8n ~ 0.100
MiningCandidate_Stringify_Long-4 1.360µ 1.339µ ~ 0.700
MiningSolution_Stringify-4 686.1n 688.9n ~ 0.400
BlockInfo_MarshalJSON-4 1.547µ 1.517µ ~ 0.100
NewFromBytes-4 125.2n 124.6n ~ 0.700
AddTxBatchColumnar_Validation-4 1.939µ 2.074µ ~ 0.100
OffsetValidationLoop-4 717.9n 546.1n ~ 0.100
Mine_EasyDifficulty-4 61.16µ 61.50µ ~ 0.100
Mine_WithAddress-4 7.213µ 7.400µ ~ 0.700
BlockAssembler_AddTx-4 0.02651n 0.02860n ~ 0.400
AddNode-4 10.82 10.59 ~ 0.700
AddNodeWithMap-4 11.18 11.24 ~ 1.000
DirectSubtreeAdd/4_per_subtree-4 45.57n 46.54n ~ 0.200
DirectSubtreeAdd/64_per_subtree-4 22.85n 23.10n ~ 0.700
DirectSubtreeAdd/256_per_subtree-4 21.92n 21.87n ~ 0.200
DirectSubtreeAdd/1024_per_subtree-4 20.76n 20.79n ~ 1.000
DirectSubtreeAdd/2048_per_subtree-4 20.47n 20.51n ~ 0.400
SubtreeProcessorAdd/4_per_subtree-4 184.8n 189.2n ~ 0.200
SubtreeProcessorAdd/64_per_subtree-4 184.0n 182.9n ~ 1.000
SubtreeProcessorAdd/256_per_subtree-4 183.7n 183.1n ~ 0.600
SubtreeProcessorAdd/1024_per_subtree-4 178.1n 175.1n ~ 0.400
SubtreeProcessorAdd/2048_per_subtree-4 178.3n 176.3n ~ 0.100
SubtreeProcessorRotate/4_per_subtree-4 179.6n 177.1n ~ 0.100
SubtreeProcessorRotate/64_per_subtree-4 179.2n 176.7n ~ 0.100
SubtreeProcessorRotate/256_per_subtree-4 177.9n 175.8n ~ 0.100
SubtreeProcessorRotate/1024_per_subtree-4 178.4n 176.3n ~ 0.400
SubtreeNodeAddOnly/4_per_subtree-4 45.59n 47.86n ~ 0.700
SubtreeNodeAddOnly/64_per_subtree-4 29.73n 28.42n ~ 0.100
SubtreeNodeAddOnly/256_per_subtree-4 28.88n 27.55n ~ 0.100
SubtreeNodeAddOnly/1024_per_subtree-4 28.24n 26.95n ~ 0.100
SubtreeCreationOnly/4_per_subtree-4 88.77n 108.30n ~ 0.100
SubtreeCreationOnly/64_per_subtree-4 312.4n 329.4n ~ 0.100
SubtreeCreationOnly/256_per_subtree-4 1.034µ 1.135µ ~ 0.700
SubtreeCreationOnly/1024_per_subtree-4 3.332µ 3.612µ ~ 0.100
SubtreeCreationOnly/2048_per_subtree-4 5.899µ 6.422µ ~ 0.100
SubtreeProcessorOverheadBreakdown/64_per_subtree-4 179.1n 178.6n ~ 0.100
SubtreeProcessorOverheadBreakdown/1024_per_subtree-4 178.7n 178.0n ~ 1.000
ParallelGetAndSetIfNotExists/1k_nodes-4 7.175m 10.798m ~ 0.100
ParallelGetAndSetIfNotExists/10k_nodes-4 11.18m 13.23m ~ 0.100
ParallelGetAndSetIfNotExists/50k_nodes-4 13.89m 15.58m ~ 0.100
ParallelGetAndSetIfNotExists/100k_nodes-4 15.81m 18.79m ~ 0.100
SequentialGetAndSetIfNotExists/1k_nodes-4 7.346m 10.560m ~ 0.100
SequentialGetAndSetIfNotExists/10k_nodes-4 12.26m 14.74m ~ 0.100
SequentialGetAndSetIfNotExists/50k_nodes-4 19.24m 20.04m ~ 0.400
SequentialGetAndSetIfNotExists/100k_nodes-4 27.08m 23.25m ~ 0.100
ProcessOwnBlockSubtreeNodesParallel/1k_nodes-4 8.889m 9.399m ~ 0.700
ProcessOwnBlockSubtreeNodesParallel/10k_nodes-4 12.91m 12.94m ~ 0.700
ProcessOwnBlockSubtreeNodesParallel/100k_nodes-4 15.49m 16.51m ~ 0.400
ProcessOwnBlockSubtreeNodesSequential/1k_nodes-4 11.42m 10.67m ~ 0.700
ProcessOwnBlockSubtreeNodesSequential/10k_nodes-4 20.93m 14.61m ~ 0.200
ProcessOwnBlockSubtreeNodesSequential/100k_nodes-4 43.93m 55.70m ~ 0.100
DiskTxMap_SetIfNotExists-4 3.392µ 3.756µ ~ 0.200
DiskTxMap_SetIfNotExists_Parallel-4 3.229µ 3.319µ ~ 1.000
DiskTxMap_ExistenceOnly-4 314.4n 400.3n ~ 0.100
Queue-4 147.1n 151.7n ~ 0.100
AtomicPointer-4 2.505n 2.547n ~ 0.100
TxMapSetIfNotExists-4 38.32n 38.31n ~ 1.000
TxMapSetIfNotExistsDuplicate-4 32.24n 32.40n ~ 0.100
ChannelSendReceive-4 420.9n 425.9n ~ 0.200
CalcBlockWork-4 517.7n 505.5n ~ 0.100
CalculateWork-4 691.1n 683.7n ~ 0.700
CheckOldBlockIDs/on-chain-prefetch/1000-4 42.01µ 43.18µ ~ 0.400
CheckOldBlockIDs/in-memory-chain-check/1000-4 1.056m 1.058m ~ 0.700
CheckOldBlockIDs/on-chain-prefetch/10000-4 350.7µ 305.6µ ~ 0.100
CheckOldBlockIDs/in-memory-chain-check/10000-4 1.613m 1.615m ~ 1.000
BuildBlockLocatorString_Helpers/Size_10-4 1.109µ 1.104µ ~ 0.700
BuildBlockLocatorString_Helpers/Size_100-4 10.42µ 10.40µ ~ 0.400
BuildBlockLocatorString_Helpers/Size_1000-4 103.1µ 103.2µ ~ 0.700
CatchupWithHeaderCache-4 105.5m 105.5m ~ 1.000
_BufferPoolAllocation/16KB-4 4.034µ 6.083µ ~ 0.200
_BufferPoolAllocation/32KB-4 9.390µ 9.600µ ~ 1.000
_BufferPoolAllocation/64KB-4 17.33µ 18.41µ ~ 0.100
_BufferPoolAllocation/128KB-4 33.11µ 36.36µ ~ 0.100
_BufferPoolAllocation/512KB-4 133.0µ 120.0µ ~ 0.100
_BufferPoolConcurrent/32KB-4 20.21µ 21.20µ ~ 0.200
_BufferPoolConcurrent/64KB-4 32.71µ 33.98µ ~ 0.200
_BufferPoolConcurrent/512KB-4 165.4µ 162.7µ ~ 1.000
_SubtreeDeserializationWithBufferSizes/16KB-4 756.5µ 725.4µ ~ 0.200
_SubtreeDeserializationWithBufferSizes/32KB-4 647.3µ 629.8µ ~ 0.400
_SubtreeDeserializationWithBufferSizes/64KB-4 633.0µ 617.2µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/128KB-4 637.3µ 632.6µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/512KB-4 636.3µ 655.9µ ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/16KB-4 36.27m 36.32m ~ 1.000
_SubtreeDataDeserializationWithBufferSizes/32KB-4 36.16m 36.15m ~ 1.000
_SubtreeDataDeserializationWithBufferSizes/64KB-4 36.35m 36.48m ~ 0.400
_SubtreeDataDeserializationWithBufferSizes/128KB-4 36.26m 35.90m ~ 0.200
_SubtreeDataDeserializationWithBufferSizes/512KB-4 36.22m 36.34m ~ 0.700
_PooledVsNonPooled/Pooled-4 741.5n 738.8n ~ 0.100
_PooledVsNonPooled/NonPooled-4 8.182µ 8.443µ ~ 0.700
_MemoryFootprint/Current_512KB_32concurrent-4 7.010µ 7.026µ ~ 1.000
_MemoryFootprint/Proposed_32KB_32concurrent-4 9.886µ 9.472µ ~ 0.400
_MemoryFootprint/Alternative_64KB_32concurrent-4 9.559µ 9.233µ ~ 0.100
_prepareTxsPerLevel-4 303.9m 306.5m ~ 0.700
_prepareTxsPerLevelOrdered-4 2.800m 2.629m ~ 0.100
_prepareTxsPerLevel_Comparison/Original-4 306.6m 308.5m ~ 1.000
_prepareTxsPerLevel_Comparison/Optimized-4 2.668m 2.563m ~ 0.100
SubtreeSizes/10k_tx_4_per_subtree-4 1.418m 1.299m ~ 0.700
SubtreeSizes/10k_tx_16_per_subtree-4 330.8µ 317.6µ ~ 0.100
SubtreeSizes/10k_tx_64_per_subtree-4 79.98µ 76.76µ ~ 0.100
SubtreeSizes/10k_tx_256_per_subtree-4 20.23µ 19.45µ ~ 0.700
SubtreeSizes/10k_tx_512_per_subtree-4 10.034µ 9.685µ ~ 0.100
SubtreeSizes/10k_tx_1024_per_subtree-4 5.032µ 4.809µ ~ 0.100
SubtreeSizes/10k_tx_2k_per_subtree-4 2.511µ 2.399µ ~ 0.100
BlockSizeScaling/10k_tx_64_per_subtree-4 79.90µ 75.88µ ~ 0.100
BlockSizeScaling/10k_tx_256_per_subtree-4 20.19µ 19.38µ ~ 0.100
BlockSizeScaling/10k_tx_1024_per_subtree-4 4.991µ 4.861µ ~ 0.100
BlockSizeScaling/50k_tx_64_per_subtree-4 404.9µ 389.0µ ~ 0.200
BlockSizeScaling/50k_tx_256_per_subtree-4 99.59µ 95.20µ ~ 0.100
BlockSizeScaling/50k_tx_1024_per_subtree-4 25.08µ 23.71µ ~ 0.100
SubtreeAllocations/small_subtrees_exists_check-4 164.6µ 153.5µ ~ 0.100
SubtreeAllocations/small_subtrees_data_fetch-4 169.3µ 165.1µ ~ 0.100
SubtreeAllocations/small_subtrees_full_validation-4 333.2µ 318.7µ ~ 0.100
SubtreeAllocations/medium_subtrees_exists_check-4 10.172µ 9.468µ ~ 0.100
SubtreeAllocations/medium_subtrees_data_fetch-4 10.69µ 10.30µ ~ 0.100
SubtreeAllocations/medium_subtrees_full_validation-4 20.51µ 19.71µ ~ 0.100
SubtreeAllocations/large_subtrees_exists_check-4 2.496µ 2.356µ ~ 0.100
SubtreeAllocations/large_subtrees_data_fetch-4 2.654µ 2.580µ ~ 0.100
SubtreeAllocations/large_subtrees_full_validation-4 5.092µ 4.975µ ~ 0.100
StoreBlock_Sequential/BelowCSVHeight-4 221.2µ 221.0µ ~ 0.700
StoreBlock_Sequential/AboveCSVHeight-4 220.4µ 220.9µ ~ 1.000
GetUtxoHashes-4 261.7n 265.6n ~ 0.100
GetUtxoHashes_ManyOutputs-4 45.99µ 42.15µ ~ 0.100
MetaBytes-4 88.15n 87.60n ~ 0.200
_NewMetaDataFromBytes-4 279.4n 277.8n ~ 0.700
_Bytes-4 395.0n 388.2n ~ 0.200
_MetaBytes-4 137.0n 136.2n ~ 0.400

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

@icellan icellan 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.

The wedge is real and the shape of the guard (depth-gated, ANDed across slots, scoped to not-found) is defensible. But the safety proof — "an absent record has no BlockIDs, so it is definitionally not mined on our chain" — holds only for a miss on the walk root, and the code applies it to a not-found from anywhere in GetConflictingChildren's BFS cone. That turns a liveness fix into a consensus fail-open. Three further paths reach the same fail-open, the tolerance is also armed on the ProcessConflicting demotion path where it converts a clean abort into a half-committed mutation, and both end-to-end tests pass an empty blockIds map, so the branch that actually decides accept/reject is unreachable in them — every finding below passes the new tests unchanged.

Blocking

  • stores/utxo/process_conflicting.go:1297 — root and descendant not-found are indistinguishable. GetConflictingChildren reads the root and every descendant through the same s.Get(...) (:1126) and returns the raw error. Parent P mined at 900, tip 1000, retention 288; P:0 names counter X whose record exists with BlockIDs=[7]; X names a grandchild Y that is absent. The walk 404s on Y, the guard checks only P's depth and continues, X never enters counterConflictingMap, so checkCounterConflictingOnCurrentChain (SubtreeValidation.go:527-534) never matches blockIds[7] and the node accepts a block double-spending an output already spent by a confirmed tx. Reproduced independently twice. Only tolerate when the root read is the not-found.

  • process_conflicting.go:1407withinRetention has no zero/lagging-tip guard, and a low tip is the permissive direction: minHeight+retention > 0 is true for every mined parent. Reproduced with the PR's own buried case (parent at height 10, GetBlockHeight()==0) — tolerated, err == nil. The tip is published only by stores/utxo/factory/utxo.go:139-157, which logs-and-skips on error or 0, never starts with startBlockchain=false, and refreshes only on block notifications, so it lags during catchup — and lagging low widens the window. set_mined_expressions.go:301-305 already stopped trusting this cached value for DAH stamping for the same reason. Minimum: if tipHeight == 0 { return false }.

  • process_conflicting.go:1368newParentDepthInfo returns unmined:true on UnminedSince != 0 before looking at BlockHeights, and unmined tolerates unconditionally at any depth. A record legitimately carries both: Aerospike MarkTransactionsOnLongestChain(false) writes only the unminedSince bin (longest_chain.go:62) and never clears blockHeights; SQL does the same (sql.go:4590-4598). After any reorg or fork promotion a parent has BlockHeights=[100] and UnminedSince=95 at tip 1000, and the guard declares it top-of-chain and tolerates a counter mined on that buried slot and pruned 600 blocks ago. Prefer the mined height whenever len(BlockHeights) > 0.

  • process_conflicting.go:1325 — tolerance is also armed on the demotion path (aerospike/conflicting.go:28, sql/sql.go:4260), where dropping the loser does not avoid the wedge, it relocates it after two committed mutation steps. MarkConflictingRecursively derives affected spends from W's own inputs; Unspend matches on AND spending_data = $3 (sql.go:3014-3028) so the slot holding X is a no-op; SpendAndCreate(W, WithSpendOnly, ...) then hits sql.go:2255-2264 "already spent by a different transaction" and returns UtxoSpentError — there is no ignore-spent flag (Interface.go:187-194). Pre-PR that input aborted cleanly with zero mutation; now it fires the compensating rollback and, on rollback failure, "MANUAL INTERVENTION REQUIRED" (:213) — on the path whose own comment says a failure there wedges block assembly on the block forever. No test covers ProcessConflicting with the new retention argument.

Should fix before merge

  • process_conflicting.go:1328 — the tolerate continue skips the frozen-sentinel scan over the spender's cone, and since #1393 this loop is the only place counter-spender cones are frozen-checked (SubtreeValidation.go:513-518 states that invariant explicitly). Combined with the first finding the cone is partially walkable and can hold an alert-system frozen sentinel, so a block spending a frozen UTXO is accepted.

  • process_conflicting.go:1297errors.Is on teranode errors matches a code anywhere in the wrap chain (errors/errors.go:180-200), so a StorageError wrapping a blob NotFound — exactly what aerospike/get.go:1938-1941 returns when a large tx's external blob is unreadable or pruned — is classified as a never-created loser and tolerated. That is a data-availability fault swallowed as absence. DoesNotTolerateNonNotFoundWalkError uses a bare ProcessingError that wraps nothing, so it does not cover this.

  • process_conflicting.go:1392 — the premise "a mined spender's DAH is mined_height + retention" is false for conflicting-marked records, which is the normal state for cone members after MarkConflictingRecursively. teranode.lua:990-1001 sets DAH once for conflicting records and never raises it, buildDeleteAtHeightExpression mirrors that guard (set_mined_expressions.go:184-187), and SQL SetConflicting stamps from the cached tip (sql.go:4280). A record marked while the cache reads 500 against a real tip of 1200 gets DAH 789 and is prune-eligible immediately — a record mined on the current chain and pruned inside the parent's window, which is what the guard claims cannot exist. This is the residual risk the description flags; the audit fails.

  • process_conflicting.go:1297GetConflictingChildren fans a BFS level over conflictingWalkFanOut concurrent Gets and errgroup keeps only the first error, so whether the walk surfaces as NotFound (tolerated) or a storage error (propagated) is a scheduling race when a level mixes a transient Aerospike failure with a genuine dangling ref. Same store state, different accept/reject across runs, and the racy outcome is the fail-open one.

  • services/subtreevalidation/counter_conflicting_dangling_test.go:103 — both end-to-end tests pass map[uint32]bool{}, so the branch that returns ErrTxInvalid (SubtreeValidation.go:530-536) can never fire: the tolerate test asserts only that the walk did not error, the fail-closed test only that it did. Missing cases: a counter whose record is present and mined in a block inside blockIds (must reject); a not-found originating from a descendant rather than the spender (must reject); ProcessConflicting over a dangling slot. Also util/test/helpers.go:28 sets GlobalBlockHeightRetention = 10, so these tests run a 10-block window while the chosen heights and comments read as though 288 applied, and the retention actually in force is never asserted.

  • process_conflicting.go:1224 — adding fields.BlockHeights to the parent Get introduces a new hard failure on Aerospike: processBlockHeights returns a StorageError when the bin is absent (get.go:1195-1199), where processBlockIDs returns empty and nil for the same condition (get.go:1149-1153). A parent record from an older node version or a snapshot restore previously read fine under fields.Utxos alone and now fails ERR_STORAGE, which is not in the tolerated class, so the block wedges. A change made to enable a fail-open guard adds a new fail-closed wedge on the production backend, and both new test files are sqlitememory/mock only.

  • stores/utxo/sql/sql.go:4260 — unguarded s.settings.GetUtxoStoreBlockHeightRetention(). sql.CreateMockStore (sql/mock.go:155-161) builds &Store{db, logger} with nil settings, and sql.go:3337-3339 already guards the identical call. Unreached today, so CI stays green over a live nil-deref path.

Also worth a look, non-blocking: retention == 0 is documented as the off switch but cannot be set in production without disabling UTXO pruning cluster-wide, and is semantically inverted (in that deployment every absent record really is a never-created dangling ref, yet the guard rejects all of them); the comment at :1221 justifying the extra fields.BlockIDs is wrong — needsBlockIDsQuery (sql/sql.go:1784-1788) already returns true for BlockHeights alone, and on Aerospike addAbstractedBins (get.go:703-711) drags SubtreeIdxs off the wire on every parent read; spenderParents (:1269) appends one hash per input with no dedup, so a large fan-in tx builds megabytes of duplicates and repeats the same map lookup, paid on every call including the common no-absence case.

@freemans13 freemans13 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Not ready to merge. I worked this independently of the review already sitting on it, so I will skip everything icellan raised and cover two things instead: what I could actually reproduce of the central objection, and one consequence I have not seen raised anywhere.

The root-versus-descendant confusion is real, and here is a repro

icellan's first blocking point is that GetConflictingChildren reads the spender and every one of its descendants through the same s.Get call and hands back the raw error, so the guard cannot tell "this spender was never created" from "something three levels down is missing". I did not take that on trust. I wrote a throwaway test against MockUtxostore on this commit:

  • parent P mined at height 900, node tip 1000, retention 288, so the guard is armed
  • P's output 0 names spender X
  • X's own record reads fine and carries BlockIDs = [7]
  • X names a child Y, and Y is absent

Result:

err = <nil>
result = [27ad8841f3b101a25986fef816a5c72425a5a16c0bff8cc1f0d687e7db8b37a3]   // the tx itself, nothing else
spenderX (mined in block 7) present in counter set? false

X is a transaction that exists and is mined in block 7, and the walk dropped it. checkCounterConflictingOnCurrentChain never fetches its meta, so the blockIds[7] test at SubtreeValidation.go:530 never runs, and the block is accepted. That is a double-spend getting through, not a theoretical hole. Confirmed.

The guard has a shelf life, and nothing repairs the reference

This is the part I have not seen raised. Tolerance is computed against the node's current tip, so one unchanged store state gives different answers as the chain grows. Parent mined at 900, retention 288, measured on this commit:

tip=1000   ->  ACCEPT (tolerated)
tip=1187   ->  ACCEPT (tolerated)
tip=1188   ->  REJECT
tip=1200   ->  REJECT

Nothing here removes the dangling reference from the parent's output slot. The PR counts it in dangling_spender_ref_tolerated_total and moves on. So the reference is still sitting there at height 1188, and every later conflicting transaction touching that parent hits the same absent record, now outside the window, and block validation stops dead again. The fix buys retention blocks. At 288 on mainnet that is roughly two days.

Past that point the failure does not even reach the guard. Once a parent's outputs are all spent it gets a delete-at-height of mined_height + retention, which is the same boundary withinRetention uses, so the pruner deletes the parent at the moment tolerance expires. The next walk then fails at the parent read, and that read has no guard on it:

parent itself pruned -> err = TX_NOT_FOUND (30): parent pruned (guard never consulted)

That path is unchanged from before this PR, so it is not a regression. It does mean the fail-closed branch the whole design is built around is not where the recurrence lands.

I would rather see the spend-first window closed at source (#1355), or a repair that clears the orphaned slot, than a time-limited tolerance that hides the inconsistency until it runs out.

What I did not check

I ran only the two packages this PR touches, ./stores/utxo/ and ./services/subtreevalidation/, both green on 8bd866a, plus the two throwaway probes above, which I have deleted. No Aerospike tests and no long tests, so icellan's Aerospike-specific claims about the demotion path are unverified by me. I did read processBlockHeights at stores/utxo/aerospike/get.go:1195 and it does return a StorageError where processBlockIDs returns empty and nil for the same missing bin, so that asymmetry is at least real.

I have not run any of this on a node.

Posting as a comment rather than a second changes-requested, since icellan's block already stands on this commit.


// Equivalent to minHeight > tipHeight - retention, written as addition to
// avoid unsigned underflow when tipHeight < retention.
return d.minHeight+retention > tipHeight

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is where the shelf life comes from. tipHeight is the validating node's current tip, so the accept-or-reject answer for one unchanged store state flips as the chain grows. Measured on this commit with the parent mined at 900 and retention 288: tolerated at tip 1000 and 1187, rejected at 1188 and 1200.

Because nothing clears the dangling reference from the parent's output slot, a parent that validates fine today simply stops validating once the tip passes 1188, and stays that way.

// fields.BlockIDs is requested alongside fields.BlockHeights because the SQL
// backend only populates BlockHeights when the block-id join is loaded; the
// parent-depth guard below relies on BlockHeights being present for a mined parent.
parentTxMeta, err := s.Get(ctx, parentTxHash, fields.Utxos, fields.BlockHeights, fields.BlockIDs, fields.UnminedSince)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Worth noting where the recurrence actually lands. A parent whose outputs are all spent gets a delete-at-height of mined_height + retention, the same boundary withinRetention uses below, so the pruner deletes the parent at the exact moment tolerance expires.

This read then returns TX_NOT_FOUND and line 1226 returns it raw, with no guard on it at all. That is unchanged from before the PR, but it means the fail-closed branch further down is not the path the recurrence takes.

The tolerance added in the previous commit is sound only if the store never
deletes a transaction mined on the longest chain before mined_height + retention.
A counter mined on a parent's output slot has mined_height >= parent_height, so
its deletion height is at or beyond the parent's; that is what lets a recent
parent stand in for the absent counter.

Pin the horizon at its boundary, on a real store driven by the real pruner
service. The earliest stamp the code can produce for a mined transaction comes
from SetMinedMulti (newDAH = minedBlockInfo.BlockHeight + retention); the spend
path stamps tip + 1 + retention, which is strictly later. So the worst case is a
transaction mined with its outputs already spent, and that is what the tests
build: it survives pruning at mined_height + retention - 1 and is deleted at
mined_height + retention.

The third test covers the one path that could stamp a mined transaction early: a
transaction flagged conflicting while unmined carries DAH = flag_height +
retention, and flag_height can precede the mined height. SetMinedMulti bumps that
stale stamp forward, so the horizon holds.

The conflicting flag is written with direct SQL because Store.SetConflicting
deadlocks on SQLite — it opens a transaction at sql.go:4325 and then calls
s.GetSpend on the pool at sql.go:4383, which cannot get a connection until the
transaction commits. That is pre-existing and untouched here; the existing sql
tests never reach it because they all pass an empty hash slice.
// SVNode-following peers accept it. Below that window we cannot rule out
// a mined-then-pruned counter, so we fail closed: SVNode would reject a
// block double-spending a confirmed output.
if errors.Is(err, errors.ErrTxNotFound) || errors.Is(err, errors.ErrNotFound) {

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.

[Major] The tolerance keys off the root spender's parent depth, but GetConflictingChildren returns ErrTxNotFound/ErrNotFound for any absent node in the BFS, not just the root. The descendant reads at process_conflicting.go:1127 propagate their error unchanged through g.Wait() (:1136-1138), so this branch cannot distinguish "root spender record absent" from "root spender exists but one of its descendants is a dangling ref".

In the second case the premise the comment relies on — "an absent record has no BlockIDs, so it is definitionally not mined on our chain" — does not hold: the record actually being dropped from the counter set (spendingTxID) may exist and be mined on our chain. If spendingTxID is a genuinely mined-on-chain counter (exactly the confirmed double-spend this check exists to reject) and merely one of its descendants is an absent dangling ref, tolerance fires on spenderParents[spendingTxID] being recent, spendingTxID is silently excluded, and checkCounterConflictingOnCurrentChain never inspects its BlockIDs (SubtreeValidation.go:531-537) — so a block double-spending a confirmed output is accepted rather than rejected. A descendant dangling ref is producible by the same spend-first window (#1214) at any depth, so this is not purely hypothetical.

Suggest confirming the guard's premise applies to the thing being dropped: e.g. before tolerating, do a direct s.Get(ctx, &spendingTxID, fields.Tx) and only tolerate when the root spender's own record is NotFound; if the root exists, the NotFound came from a descendant and should propagate (or be handled on its own evidence). This is a static-analysis concern — I could not execute the tests to confirm reachability; worth verifying with a targeted case (mined root + absent descendant).

@freemans13 freemans13 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Still not mergeable, and the update does not move it. The new commit is test-only. git diff --name-only 8bd866aa f79e2ce9 lists one file, stores/utxo/sql/prune_horizon_test.go, so every blocking finding on 8bd866a stands untouched, including the root-versus-descendant fail-open that icellan, the review bot and I have now each found separately. The bot's note on that one says it could not execute anything and asks for "a targeted case (mined root + absent descendant)". That case is already on this PR, with output, in my 3 September review.

What the new commit does address is the residual risk paragraph in the description, the claim that the pruner never deletes a mined transaction before mined_height + retention. I ran the three tests on f79e2ce and they pass. They do not settle the question, for two reasons.

The audit is SQL-only, and its result inverts on Aerospike

The third test proves that SetMinedMulti bumps a stale flag-height stamp forward, so a transaction flagged conflicting and then mined keeps a deletion height at or beyond mined_height + retention. That is true of the SQL store. It is not true of Aerospike, which is the production backend, and the test comment itself points at the Lua branch where it breaks.

The mined path calls setDeleteAtHeight(rec, blockHeight, blockHeightRetention) at teranode.lua:663. The first thing that function does with a conflicting record is this:

if rec[BIN_CONFLICTING] then
    if not existingDeleteAtHeight then
        rec[BIN_DELETE_AT_HEIGHT] = newDeleteHeight
        ...
    end
    return "", nil
end

It sets the stamp only when there is no stamp, then returns unconditionally. There is no bump. So the exact behaviour the third test relies on to close the hazard is absent on the backend that matters, and the tests cannot catch it because they run against SQL. Worth noting the Aerospike native-ops job on this run reports skipping.

Mined first, then flagged conflicting, is not covered

The three tests all flag conflicting before mining. The reverse order behaves differently on SQL too, because SetConflicting stamps GetBlockHeight() + 1 + retention from the store's cached tip, and that cache lags. It is published on block notifications only and sits at 0 when the store starts with startBlockchain false.

Probe on f79e2ce, retention 10, real store, real pruner service:

after SetMinedMulti(height=1500, unspent outputs): delete_at_height = <nil>
after conflicting flag with cached tip 500:        delete_at_height = 511
the guard assumes this tx survives to mined_height + retention = 1510
PRUNED at tip 515, which is 995 blocks BEFORE mined_height+retention (1510)

A transaction mined on the longest chain at 1500 is deleted at 515. The outputs are left unspent on purpose, because that is what leaves delete_at_height null, and null is the one condition under which the COALESCE(delete_at_height, $3) in SetConflicting writes anything at all. Nothing bumps it afterwards, since SetMinedMulti already ran.

Whether a counter-conflicting transaction specifically can reach that ordering is the question the audit needs to answer. A reorg demoting a previously mined transaction is the obvious candidate. The audit as written does not answer it either way.

The SQLite deadlock is real, and it needs its own issue

I checked the claim in the test comment rather than taking it. Store.SetConflicting opens a write transaction with s.db.Begin(), issues the UPDATE through it, and then calls s.GetSpend on the pool, which cannot get a connection until that transaction commits. Called with one real hash it does not return within 5 seconds.

That is pre-existing and this PR is right not to fix it here. But a production-code deadlock currently exists only as a comment inside a test file on an unrelated PR, and the existing SQL tests miss it because they all pass an empty hash slice. It should be an issue in its own right, otherwise it is lost the moment this branch merges or closes.

What I did not check

I ran ./stores/utxo/sql/ on f79e2ce, the three new tests pass, plus two throwaway probes which I have deleted. Nothing on Aerospike, so the Lua reading above is static only, not executed. Nothing on a node.

The one red check, legacy-sync, fails because an SVNode container could not bind RPC port 48332 on the runner. That is a port collision, unrelated to this branch.


require.NoError(t, store.db.QueryRowContext(ctx,
`SELECT delete_at_height FROM transactions WHERE hash = $1`, txHash[:]).Scan(&stamped))
require.GreaterOrEqual(t, stamped, int64(mineHeight+retention),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This holds for the SQL store. It does not hold for Aerospike, and the Lua branch named two lines up is where it stops holding.

The mined path calls setDeleteAtHeight(rec, blockHeight, blockHeightRetention) at teranode.lua:663, and that function opens with:

if rec[BIN_CONFLICTING] then
    if not existingDeleteAtHeight then
        rec[BIN_DELETE_AT_HEIGHT] = newDeleteHeight
        ...
    end
    return "", nil
end

It writes a stamp only when there is none, then returns. No bump. So the bump this test proves, and that the guard depends on, does not happen on the production backend for exactly the records at issue. The Aerospike native-ops job on this run reports skipping, so nothing here would catch it.

// sql.go:4325 and then calls s.GetSpend on the pool at sql.go:4383, which
// deadlocks on SQLite. What is under test here is the DAH arithmetic in
// SetMinedMulti, and this sets up its precondition exactly.
func TestPruneHorizon_ConflictingThenMinedDoesNotKeepEarlierStamp(t *testing.T) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

All three tests flag conflicting before mining. The reverse order is the one that bites, and it is uncovered.

SetConflicting stamps GetBlockHeight() + 1 + retention from the store's cached tip, which lags (published on block notifications only, and 0 when the store starts with startBlockchain false). Probe against a real store and the real pruner, retention 10:

after SetMinedMulti(height=1500, unspent outputs): delete_at_height = <nil>
after conflicting flag with cached tip 500:        delete_at_height = 511
PRUNED at tip 515, which is 995 blocks BEFORE mined_height+retention (1510)

Outputs left unspent on purpose: that is what keeps delete_at_height null, and null is the only case where the COALESCE(delete_at_height, $3) in SetConflicting writes anything. SetMinedMulti has already run by then, so nothing bumps it back.

@icellan

icellan commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Re-reviewed at f79e2ce. The new commit is 211 lines of test in stores/utxo/sql/prune_horizon_test.go and no production change, so the four blocking findings are all still open and still at the same lines.

On the one they do engage with — the prune-horizon premise — the result holds for SQL and not for Aerospike.

SQL bumps a stale stamp forward, as the commit message says: setMinedMultiChunk (sql.go:3361-3364) has WHEN delete_at_height IS NOT NULL AND delete_at_height < $1 THEN $1. So a record flagged conflicting while unmined does get lifted to mined_height + retention when it is later mined, and the horizon the guard depends on holds there.

Aerospike refuses that bump on both paths:

  • teranode.lua:990-999 handles conflicting first and returns early — if rec[BIN_CONFLICTING] then if not existingDeleteAtHeight then ... end; return "", nil end. An existing DAH is never raised.
  • set_mined_expressions.go:241ExpAnd(isConflicting, dahNotExists) sets the DAH, everything else falls through to ExpUnknown(). Same semantics.

The comment at sql.go:3350-3353 says the SQL CASE "Mirrors aerospike Lua setDeleteAtHeight logic". It does not: it diverges on exactly the case this commit pins. Combined with SetConflicting stamping from the cached tip (sql.go:4280, and the Aerospike equivalent), a record marked conflicting while the cache reads 500 against a real tip of 1200 keeps DAH 789 through being mined at 1200 and is pruned inside a parent's tolerate window — the state the guard asserts cannot exist.

So the horizon is pinned on the backend where it already held, and the commit message states the conclusion generally. The test is stores/utxo/sql only, so the Aerospike coverage gap noted against process_conflicting.go:1224 is also unchanged.

Separately, worth its own issue rather than anything here: the deadlock you documented in the commit message is real and pre-existing — SetConflicting opens a transaction at sql.go:4325 and then calls s.GetSpend on the pool at sql.go:4383, which cannot get a connection until that transaction commits. The existing sql tests miss it because they all pass an empty hash slice.

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