@@ -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).
537666func (r * RescanManager ) AddUTXO (txHash string , vout uint32 , value int64 , addrStr string , scriptPubKey []byte , height int32 ) {
538667 r .mu .Lock ()
0 commit comments