Skip to content

fix(walletstore): reconcile local-spend placeholder to the confirmed height (#101)#102

Merged
LiranCohen merged 1 commit into
mainfrom
fix/walletstore-localspend-reconcile
Jun 8, 2026
Merged

fix(walletstore): reconcile local-spend placeholder to the confirmed height (#101)#102
LiranCohen merged 1 commit into
mainfrom
fix/walletstore-localspend-reconcile

Conversation

@LiranCohen

Copy link
Copy Markdown
Contributor

Summary

Closes audit 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 sqlite's UPDATE had AND spent=0, so spent_height stayed at X.

The reorg un-spend is keyed on spent_height (bbolt scans the spent-index from forkHeight up; sqlite WHERE 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

ApplyBlock now reconciles the placeholder to the real confirmed height:

  • bbolt.go: markSpentConfirmed deletes the old spentKey(SpentHeight, op), sets SpentHeight = height, re-keys to spentKey(height, op) (idempotent when already at this height).
  • sqlite.go: the ApplyBlock spend UPDATE drops AND spent=0.

MarkSpentLocal keeps 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=0 is safe under the caller contract — the scanner runs reconcileReorg (Rollback to the fork) before re-applying blocks, and only the stable frontier tip - confirmationDepth is 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

TestLocalSpendReconcilesToConfirmedHeight runs 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

  • What to watch: after a reorg that orphans a broadcast-but-unconfirmed (or shallowly-confirmed) anchoring spend, the funding UTXO should reappear as spendable (Balance/ListSpendable) instead of vanishing. Funding-wallet balance should reconcile with on-chain reality after reorgs.
  • Failure signal / trigger: funding balance dropping permanently after a reorg with no corresponding confirmed spend → investigate (should no longer happen).
  • Window/owner: wallet operator, across any reorg affecting a recent anchor broadcast.

Closes #101

🤖 Generated with Claude Code

…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>
@LiranCohen LiranCohen merged commit ebea79a into main Jun 8, 2026
1 check passed
@LiranCohen LiranCohen deleted the fix/walletstore-localspend-reconcile branch June 8, 2026 03:02
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.

walletstore: local-spend placeholder height is never reconciled — a reorg can silently burn a funding UTXO

1 participant