fix(walletstore): reconcile local-spend placeholder to the confirmed height (#101)#102
Merged
Merged
Conversation
…height (#101) Finding #7 (high, real-BTC data-integrity). MarkSpentLocal optimistically marks a funding UTXO spent at a PLACEHOLDER height X at broadcast. When the spend confirmed at real height Y, ApplyBlock re-marked it — but bbolt markSpent short-circuited on already-spent and the sqlite UPDATE had `AND spent=0`, so spent_height stayed at X. The reorg un-spend is keyed on spent_height, so a reorg forking at F with X < F <= Y missed the stale entry at X and never un-spent the orphaned spend — silently BURNING the funding UTXO (never re-selected). - internal/walletstore/bbolt.go: markSpentConfirmed reconciles — deletes the old spentKey(SpentHeight,op) index entry, sets SpentHeight=height, re-keys to spentKey(height,op); idempotent when already at this height. ApplyBlock uses it (MarkSpentLocal keeps the non-overwriting markSpent). - internal/walletstore/sqlite.go: the ApplyBlock spend UPDATE drops `AND spent=0` (MarkSpentLocal keeps it). - internal/walletstore/store_test.go: TestLocalSpendReconcilesToConfirmedHeight runs the exact reorg-straddle across BOTH engines; mutation-verified it burns the UTXO without the fix (balance 0) on bolt and sqlite. Adversarial data-integrity review: both engines reach bit-for-bit parity across all (MarkSpentLocal, ApplyBlock, Rollback) interleavings, incl. confirm-below-placeholder and idempotent re-apply; no remaining burn/double-spend path. Dropping sqlite's `AND spent=0` is safe under the caller contract (scanner reconcileReorg Rollbacks before re-apply; only the stable frontier is applied). go test -race ./... green. Co-authored-by: Liran Cohen <liranlasvegas@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes audit finding #7 (high — real-BTC data integrity).
MarkSpentLocaloptimistically marks a funding UTXO spent at a placeholder height X at broadcast. When the spend confirmed at real height Y,ApplyBlockre-marked it — but bboltmarkSpentshort-circuited on already-spent and sqlite's UPDATE hadAND spent=0, sospent_heightstayed at X.The reorg un-spend is keyed on
spent_height(bbolt scans the spent-index from forkHeight up; sqliteWHERE spent=1 AND spent_height >= forkHeight). So a reorg forking at F with X < F <= Y missed the stale entry at X and never un-spent the orphaned spend → the funding UTXO was silently burned (never re-selected).Fix
ApplyBlocknow reconciles the placeholder to the real confirmed height:bbolt.go:markSpentConfirmeddeletes the oldspentKey(SpentHeight, op), setsSpentHeight = height, re-keys tospentKey(height, op)(idempotent when already at this height).sqlite.go: the ApplyBlock spend UPDATE dropsAND spent=0.MarkSpentLocalkeeps the non-overwriting path (a placeholder never clobbers a confirmed spend).Adversarial review
A data-integrity review could not break it: both engines reach bit-for-bit parity across every (
MarkSpentLocal,ApplyBlock,Rollback) interleaving — including confirm-below-placeholder (deep reorg), idempotent re-apply, and whole-height replace. No remaining burn/double-spend path.Invariant: dropping sqlite's
AND spent=0is safe under the caller contract — the scanner runsreconcileReorg(Rollback to the fork) before re-applying blocks, and only the stable frontiertip - confirmationDepthis applied, so the same outpoint is never re-emitted as spent at a new height without an intervening Rollback. (Verified in the review; a future scanner refactor that violated this would need a store-level guard.)Tests
TestLocalSpendReconcilesToConfirmedHeightruns the exact reorg-straddle across both engines; mutation-verified — without the fix it burns the UTXO (balance 0) on bolt and sqlite.go test -race ./...green (26 packages).Post-Deploy Monitoring & Validation
Balance/ListSpendable) instead of vanishing. Funding-wallet balance should reconcile with on-chain reality after reorgs.Closes #101
🤖 Generated with Claude Code