@@ -55,6 +55,15 @@ const MAX_RESTART_RETRIES: u32 = 5;
5555/// Initial backoff delay for restart retries (doubles each attempt).
5656const INITIAL_BACKOFF_MS : u64 = 500 ;
5757
58+ /// Maximum number of passes the on-chain wallet recovery loop will run before
59+ /// giving up. Each extra pass after the first re-scans the full chain history
60+ /// for newly revealed scripts past the previously scanned window. With
61+ /// `BDK_CLIENT_STOP_GAP = 20`, eight passes can recover funds across roughly
62+ /// `8 * 20 = 160` consecutive derivation indices in a single sync. Anything
63+ /// beyond that violates the BIP44 stop-gap convention and will be discovered
64+ /// over subsequent syncs instead.
65+ const MAX_RECOVERY_LOOP_ITERS : usize = 8 ;
66+
5867/// The fee estimation back-end used by the CBF chain source.
5968enum FeeSource {
6069 /// Derive fee rates from the coinbase reward of recent blocks.
@@ -586,27 +595,64 @@ impl CbfChainSource {
586595
587596 let res = async {
588597 let requester = self . requester ( ) ?;
598+ let now = Instant :: now ( ) ;
599+
600+ // Multi-pass recovery loop. On a fresh wallet `get_spks_for_cbf_sync`
601+ // only covers indices `0..stop_gap`, so funds at deeper indices are
602+ // invisible to a single scan. Each iteration:
603+ // 1. asks the wallet for scripts past the previously scanned boundary,
604+ // 2. runs a filter scan + apply_update,
605+ // 3. lets `Update.last_active_indices` advance BDK's reveal cursor,
606+ // 4. loops if the new reveal cursor extends the window past the
607+ // boundary we just scanned.
608+ // In steady state this terminates after the very first iteration since
609+ // no new revealed indices appear past the existing window. Iterations
610+ // after the first scan over the *full* chain history (skip_height = 0)
611+ // because the newly added scripts could match historical blocks.
612+ let mut prev_window_ends: BTreeMap < KeychainKind , u32 > = BTreeMap :: new ( ) ;
613+ let mut iter: usize = 0 ;
614+ let mut total_matched_blocks: usize = 0 ;
615+
616+ loop {
617+ let ( scripts, spk_to_keychain_idx, window_ends) =
618+ onchain_wallet. get_spks_for_cbf_sync ( BDK_CLIENT_STOP_GAP , & prev_window_ends) ;
619+
620+ if scripts. is_empty ( ) {
621+ if iter == 0 {
622+ log_debug ! ( self . logger, "No wallet scripts to sync via CBF." ) ;
623+ }
624+ break ;
625+ }
626+
627+ // First pass scans incrementally from BDK's checkpoint (cheap delta
628+ // sync). Subsequent passes need to scan the full chain because the
629+ // newly added scripts could match historical blocks.
630+ let skip_height = if iter == 0 {
631+ onchain_wallet. latest_checkpoint ( ) . height ( ) . checked_sub ( REORG_SAFETY_BLOCKS )
632+ } else {
633+ None
634+ } ;
589635
590- let ( scripts, spk_to_keychain_idx) =
591- onchain_wallet. get_spks_for_cbf_sync ( BDK_CLIENT_STOP_GAP ) ;
592- if scripts. is_empty ( ) {
593- log_debug ! ( self . logger, "No wallet scripts to sync via CBF." ) ;
594- } else {
595- let now = Instant :: now ( ) ;
596636 let timeout_fut = tokio:: time:: timeout (
597637 Duration :: from_secs (
598638 self . sync_config . timeouts_config . onchain_wallet_sync_timeout_secs ,
599639 ) ,
600- self . sync_onchain_wallet_op ( requester, & onchain_wallet, scripts) ,
640+ self . sync_onchain_wallet_op (
641+ requester. clone ( ) ,
642+ scripts,
643+ skip_height,
644+ /* include_registered_scripts */ iter == 0 ,
645+ ) ,
601646 ) ;
602647
603- let ( tx_update, sync_update) = match timeout_fut. await {
648+ let ( tx_update, sync_update, matched_count ) = match timeout_fut. await {
604649 Ok ( res) => res?,
605650 Err ( e) => {
606651 log_error ! ( self . logger, "Sync of on-chain wallet timed out: {}" , e) ;
607652 return Err ( Error :: WalletOperationTimeout ) ;
608653 } ,
609654 } ;
655+ total_matched_blocks += matched_count;
610656
611657 // Build chain checkpoint extending from the wallet's current tip.
612658 let mut cp = onchain_wallet. latest_checkpoint ( ) ;
@@ -622,10 +668,10 @@ impl CbfChainSource {
622668 cp = cp. push ( tip_block_id) . unwrap_or_else ( |old| old) ;
623669 }
624670
625- // Walk the matched outputs to find the highest derivation index hit per
626- // keychain. Passing these via Update.last_active_indices tells BDK to
627- // advance its reveal cursor, which in turn extends the scan window we
628- // compute in get_spks_for_cbf_sync on the next sync .
671+ // Walk the matched outputs to find the highest derivation index hit
672+ // per keychain. Passing these via Update.last_active_indices tells
673+ // BDK to advance its reveal cursor, which in turn extends the scan
674+ // window for the next loop iteration .
629675 let mut last_active_indices: BTreeMap < KeychainKind , u32 > = BTreeMap :: new ( ) ;
630676 for tx in & tx_update. txs {
631677 for txout in & tx. output {
@@ -644,10 +690,28 @@ impl CbfChainSource {
644690
645691 onchain_wallet. apply_update ( update) ?;
646692
693+ prev_window_ends = window_ends;
694+ iter += 1 ;
695+
696+ if iter >= MAX_RECOVERY_LOOP_ITERS {
697+ log_info ! (
698+ self . logger,
699+ "CBF on-chain recovery loop hit max iterations ({}); deeper funds will be discovered on subsequent syncs." ,
700+ MAX_RECOVERY_LOOP_ITERS ,
701+ ) ;
702+ break ;
703+ }
704+ }
705+
706+ if iter > 0 {
647707 log_debug ! (
648708 self . logger,
649- "Sync of on-chain wallet via CBF finished in {}ms." ,
650- now. elapsed( ) . as_millis( )
709+ "Sync of on-chain wallet via CBF finished in {}ms ({} pass{}, {} matched block{})." ,
710+ now. elapsed( ) . as_millis( ) ,
711+ iter,
712+ if iter == 1 { "" } else { "es" } ,
713+ total_matched_blocks,
714+ if total_matched_blocks == 1 { "" } else { "s" } ,
651715 ) ;
652716 }
653717
@@ -670,25 +734,28 @@ impl CbfChainSource {
670734 }
671735
672736 async fn sync_onchain_wallet_op (
673- & self , requester : Requester , onchain_wallet : & Wallet , scripts : Vec < ScriptBuf > ,
674- ) -> Result < ( TxUpdate < ConfirmationBlockTime > , SyncUpdate ) , Error > {
675- // Derive skip height from BDK's persisted checkpoint, walked back by
676- // REORG_SAFETY_BLOCKS for reorg safety (same approach as bdk-kyoto).
677- // This survives restarts since BDK persists its checkpoint chain.
737+ & self , requester : Requester , scripts : Vec < ScriptBuf > , skip_height : Option < u32 > ,
738+ include_registered_scripts : bool ,
739+ ) -> Result < ( TxUpdate < ConfirmationBlockTime > , SyncUpdate , usize ) , Error > {
740+ // We optionally include LDK-registered scripts (e.g., channel funding
741+ // output scripts) alongside the wallet scripts. This ensures the
742+ // on-chain wallet scan also fetches blocks containing channel funding
743+ // transactions, whose outputs are needed by BDK's TxGraph to calculate
744+ // fees for subsequent spends such as splice transactions. Without
745+ // these, BDK's `calculate_fee` would fail with `MissingTxOut` because
746+ // the parent transaction's outputs are unknown. This mirrors what the
747+ // Bitcoind chain source does in `Wallet::block_connected` by inserting
748+ // registered tx outputs.
678749 //
679- // We include LDK-registered scripts (e.g., channel funding output
680- // scripts) alongside the wallet scripts. This ensures the on-chain
681- // wallet scan also fetches blocks containing channel funding
682- // transactions, whose outputs are needed by BDK's TxGraph to
683- // calculate fees for subsequent spends such as splice transactions.
684- // Without these, BDK's `calculate_fee` would fail with
685- // `MissingTxOut` because the parent transaction's outputs are
686- // unknown. This mirrors what the Bitcoind chain source does in
687- // `Wallet::block_connected` by inserting registered tx outputs.
750+ // `include_registered_scripts` is `false` for the recovery loop's
751+ // follow-up passes: those passes only carry the *new* wallet scripts
752+ // past the previously scanned window, so re-scanning the full set of
753+ // channel scripts would be wasted work — they were already scanned in
754+ // the first pass.
688755 let mut all_scripts = scripts;
689- all_scripts . extend ( self . registered_scripts . lock ( ) . unwrap ( ) . iter ( ) . cloned ( ) ) ;
690- let skip_height =
691- onchain_wallet . latest_checkpoint ( ) . height ( ) . checked_sub ( REORG_SAFETY_BLOCKS ) ;
756+ if include_registered_scripts {
757+ all_scripts . extend ( self . registered_scripts . lock ( ) . unwrap ( ) . iter ( ) . cloned ( ) ) ;
758+ }
692759 let ( sync_update, matched) = self . run_filter_scan ( all_scripts, skip_height) . await ?;
693760
694761 log_debug ! (
@@ -727,7 +794,8 @@ impl CbfChainSource {
727794 }
728795 }
729796
730- Ok ( ( tx_update, sync_update) )
797+ let matched_count = matched. len ( ) ;
798+ Ok ( ( tx_update, sync_update, matched_count) )
731799 }
732800
733801 /// Sync the Lightning wallet by confirming channel transactions via compact block filters.
0 commit comments