Skip to content

Commit b5dce0d

Browse files
committed
fix: Add spend verification fallback to rescan
1 parent 83a2705 commit b5dce0d

3 files changed

Lines changed: 192 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- **Spent UTXO detection in bulk endpoint**: `POST /v1/utxos` could return already-spent UTXOs because the batch `MatchAny` filter scan missed spending blocks in certain cases. Added a per-UTXO spend verification pass after the main scan that uses single-script `filter.Match` (the same approach used by the reliable `GET /v1/utxo/{txid}/{vout}` endpoint) to catch any spends missed by the batch scan.
13+
1014
## [0.9.0] - 2026-03-19
1115

1216
### Added

neutrino_server/internal/neutrino/rescan.go

Lines changed: 133 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"github.com/btcsuite/btcd/btcutil"
1717
"github.com/btcsuite/btcd/btcutil/gcs/builder"
1818
"github.com/btcsuite/btcd/chaincfg"
19+
"github.com/btcsuite/btcd/chaincfg/chainhash"
1920
"github.com/btcsuite/btcd/txscript"
2021
"github.com/btcsuite/btcd/wire"
2122
"github.com/btcsuite/btclog"
@@ -501,11 +502,10 @@ func (r *RescanManager) scanBlocks(startHeight, endHeight int32, addrs []btcutil
501502

502503
elapsed := time.Since(scanStart)
503504

504-
// Update UTXO set
505+
// Update UTXO set (preliminary — before spend verification).
505506
r.mu.Lock()
506-
defer r.mu.Unlock()
507507

508-
// Add new UTXOs (if not spent)
508+
// Add new UTXOs (if not spent in this scan batch).
509509
added := 0
510510
for utxoKey, utxo := range foundUTXOs {
511511
if !spentOutputs[utxoKey] {
@@ -514,7 +514,7 @@ func (r *RescanManager) scanBlocks(startHeight, endHeight int32, addrs []btcutil
514514
}
515515
}
516516

517-
// Remove spent UTXOs
517+
// Remove spent UTXOs detected by the batch filter scan.
518518
removed := 0
519519
for utxoKey := range spentOutputs {
520520
if _, existed := r.utxoSet[utxoKey]; existed {
@@ -523,16 +523,145 @@ func (r *RescanManager) scanBlocks(startHeight, endHeight int32, addrs []btcutil
523523
}
524524
}
525525

526+
// Snapshot the current UTXO set for the spend verification pass.
527+
// We need to release the lock before doing network I/O.
528+
utxoSnapshot := make(map[string]UTXO, len(r.utxoSet))
529+
for k, v := range r.utxoSet {
530+
utxoSnapshot[k] = v
531+
}
532+
r.mu.Unlock()
533+
526534
blocksPerSec := float64(0)
527535
if elapsed.Seconds() > 0 {
528536
blocksPerSec = float64(blocksScanned) / elapsed.Seconds()
529537
}
530538
r.logger.Infof("Scan complete: %d blocks in %s (%.1f blocks/sec) | %d filter matches | %d UTXOs found, %d added, %d spent removed | total UTXO set: %d",
531539
blocksScanned, elapsed.Round(time.Millisecond), blocksPerSec,
532540
filterMatches, len(foundUTXOs), added, removed, len(r.utxoSet))
541+
542+
// Phase 3: Spend verification pass.
543+
// The batch MatchAny filter scan can miss spends in certain cases (e.g.
544+
// subtle differences in multi-script vs single-script filter matching).
545+
// Verify each UTXO individually using per-script filter.Match, the same
546+
// approach used by GetUTXO which is known to be reliable.
547+
verifiedSpent := r.verifyUTXOsUnspent(utxoSnapshot, startHeight, endHeight)
548+
if verifiedSpent > 0 {
549+
r.logger.Infof("Spend verification removed %d additional spent UTXOs", verifiedSpent)
550+
}
551+
533552
return nil
534553
}
535554

555+
// verifyUTXOsUnspent performs a per-UTXO spend check for all UTXOs in the
556+
// snapshot. For each UTXO, it scans block filters from the UTXO's creation
557+
// height to endHeight using single-script filter.Match (not MatchAny). Any
558+
// block that matches is downloaded and checked for transactions spending the
559+
// UTXO. Confirmed spends are removed from r.utxoSet.
560+
//
561+
// This is a defense-in-depth measure: the main batch scan (MatchAny) should
562+
// catch most spends, but this pass ensures none are missed.
563+
func (r *RescanManager) verifyUTXOsUnspent(
564+
snapshot map[string]UTXO, scanStart, scanEnd int32,
565+
) int {
566+
if len(snapshot) == 0 {
567+
return 0
568+
}
569+
570+
r.logger.Infof("Spend verification: checking %d UTXOs for spends in range %d..%d",
571+
len(snapshot), scanStart, scanEnd)
572+
verifyStart := time.Now()
573+
removed := 0
574+
575+
for utxoKey, utxo := range snapshot {
576+
// Only verify UTXOs that could have been spent in the scanned range.
577+
// A UTXO created after scanEnd cannot be spent in this range.
578+
// Start checking from the block AFTER the UTXO was created.
579+
checkFrom := utxo.Height + 1
580+
if checkFrom < scanStart {
581+
checkFrom = scanStart
582+
}
583+
if checkFrom > scanEnd {
584+
continue
585+
}
586+
587+
// Build the pkScript for this UTXO's address.
588+
addr, err := btcutil.DecodeAddress(utxo.Address, r.chainParams)
589+
if err != nil {
590+
r.logger.Debugf("Spend verify: skip %s, bad address %s: %v",
591+
utxoKey, utxo.Address, err)
592+
continue
593+
}
594+
pkScript, err := txscript.PayToAddrScript(addr)
595+
if err != nil {
596+
r.logger.Debugf("Spend verify: skip %s, script error: %v",
597+
utxoKey, err)
598+
continue
599+
}
600+
601+
// Parse the UTXO txid for outpoint comparison.
602+
targetHash, err := chainhash.NewHashFromStr(utxo.TxID)
603+
if err != nil {
604+
r.logger.Debugf("Spend verify: skip %s, bad txid: %v",
605+
utxoKey, err)
606+
continue
607+
}
608+
609+
spent := false
610+
for height := checkFrom; height <= scanEnd && !spent; height++ {
611+
blockHash, err := r.chainService.GetBlockHash(int64(height))
612+
if err != nil {
613+
continue
614+
}
615+
616+
filter, err := r.chainService.GetCFilter(*blockHash, wire.GCSFilterRegular)
617+
if err != nil || filter == nil {
618+
continue
619+
}
620+
621+
key := builder.DeriveKey(blockHash)
622+
matched, err := filter.Match(key, pkScript)
623+
if err != nil || !matched {
624+
continue
625+
}
626+
627+
// Filter matched for this single script — fetch full block.
628+
block, err := r.chainService.GetBlock(*blockHash)
629+
if err != nil {
630+
r.logger.Debugf("Spend verify: failed to get block %d: %v", height, err)
631+
continue
632+
}
633+
634+
for _, tx := range block.Transactions() {
635+
for _, txIn := range tx.MsgTx().TxIn {
636+
prevOut := txIn.PreviousOutPoint
637+
if prevOut.Hash.IsEqual(targetHash) && prevOut.Index == utxo.Vout {
638+
spent = true
639+
r.logger.Infof("Spend verify: %s spent at height %d in tx %s",
640+
utxoKey, height, tx.Hash().String())
641+
break
642+
}
643+
}
644+
if spent {
645+
break
646+
}
647+
}
648+
}
649+
650+
if spent {
651+
r.mu.Lock()
652+
if _, exists := r.utxoSet[utxoKey]; exists {
653+
delete(r.utxoSet, utxoKey)
654+
removed++
655+
}
656+
r.mu.Unlock()
657+
}
658+
}
659+
660+
r.logger.Infof("Spend verification complete: checked %d UTXOs in %s, removed %d spent",
661+
len(snapshot), time.Since(verifyStart).Round(time.Millisecond), removed)
662+
return removed
663+
}
664+
536665
// AddUTXO adds a UTXO to the set (for use by notification handlers).
537666
func (r *RescanManager) AddUTXO(txHash string, vout uint32, value int64, addrStr string, scriptPubKey []byte, height int32) {
538667
r.mu.Lock()

neutrino_server/internal/neutrino/rescan_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -576,3 +576,58 @@ func TestRescanIncrementalSkip(t *testing.T) {
576576
t.Errorf("expected LastStartHeight=243542, got %d", mgr.status.LastStartHeight)
577577
}
578578
}
579+
580+
// TestVerifyUTXOsUnspentEmptySnapshot tests that verifyUTXOsUnspent returns 0
581+
// when the snapshot is empty.
582+
func TestVerifyUTXOsUnspentEmptySnapshot(t *testing.T) {
583+
backend := btclog.NewBackend(os.Stdout)
584+
logger := backend.Logger("TEST")
585+
586+
mgr := &RescanManager{
587+
chainParams: &chaincfg.MainNetParams,
588+
logger: logger,
589+
watchedAddrs: make(map[string]btcutil.Address),
590+
utxoSet: make(map[string]UTXO),
591+
}
592+
593+
removed := mgr.verifyUTXOsUnspent(nil, 0, 100)
594+
if removed != 0 {
595+
t.Errorf("expected 0 removed for nil snapshot, got %d", removed)
596+
}
597+
598+
removed = mgr.verifyUTXOsUnspent(map[string]UTXO{}, 0, 100)
599+
if removed != 0 {
600+
t.Errorf("expected 0 removed for empty snapshot, got %d", removed)
601+
}
602+
}
603+
604+
// TestVerifyUTXOsUnspentSkipsOutOfRange tests that UTXOs created after the scan
605+
// range are skipped (no chain service calls needed).
606+
func TestVerifyUTXOsUnspentSkipsOutOfRange(t *testing.T) {
607+
backend := btclog.NewBackend(os.Stdout)
608+
logger := backend.Logger("TEST")
609+
610+
mgr := &RescanManager{
611+
// nil chainService — if it tries to call it, we'll get a panic,
612+
// proving the UTXO was NOT checked.
613+
chainParams: &chaincfg.MainNetParams,
614+
logger: logger,
615+
watchedAddrs: make(map[string]btcutil.Address),
616+
utxoSet: make(map[string]UTXO),
617+
}
618+
619+
snapshot := map[string]UTXO{
620+
"abc:0": {
621+
TxID: "abc",
622+
Vout: 0,
623+
Address: "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa",
624+
Height: 200, // created at 200, scanEnd=100 => out of range
625+
},
626+
}
627+
628+
// scanEnd < UTXO.Height, so the UTXO can't have been spent in this range.
629+
removed := mgr.verifyUTXOsUnspent(snapshot, 0, 100)
630+
if removed != 0 {
631+
t.Errorf("expected 0 removed for out-of-range UTXO, got %d", removed)
632+
}
633+
}

0 commit comments

Comments
 (0)