Skip to content

Commit 12feeff

Browse files
committed
fix(cbf): loop sync until reveal cursor stops advancing on fresh recovery
A fresh CBF recovery only scanned scripts at indices `0..stop_gap`, so funds at deeper indices were invisible to the first sync. Subsequent syncs raised `skip_height` past the funding block, leaving the deeper funds permanently undiscovered.
1 parent 303deaa commit 12feeff

3 files changed

Lines changed: 197 additions & 41 deletions

File tree

src/chain/cbf.rs

Lines changed: 100 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,15 @@ const MAX_RESTART_RETRIES: u32 = 5;
5555
/// Initial backoff delay for restart retries (doubles each attempt).
5656
const 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.
5968
enum 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.

src/wallet/mod.rs

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
// http://opensource.org/licenses/MIT>, at your option. You may not use this file except in
66
// accordance with one or both of these licenses.
77

8-
use std::collections::HashMap;
8+
use std::collections::{BTreeMap, HashMap};
99
use std::future::Future;
1010
use std::ops::Deref;
1111
use std::str::FromStr;
@@ -124,35 +124,48 @@ impl Wallet {
124124
}
125125

126126
/// Returns the on-chain scripts CBF should scan for, plus a mapping
127-
/// from each script to its `(keychain, derivation index)`.
127+
/// from each script to its `(keychain, derivation index)` and the per-keychain
128+
/// window end (exclusive) used to compute the script set.
128129
///
129-
/// For each keychain, the returned set covers indices `0..last_revealed + 1 + stop_gap`,
130+
/// For each keychain, the full window covers indices `0..last_revealed + 1 + stop_gap`,
130131
/// i.e. all already-revealed scripts plus a `stop_gap`-sized lookahead buffer past the
131132
/// last revealed index. This mirrors BDK's internal `KeychainTxOutIndex` lookahead so
132133
/// CBF also scans for funds that land at indices just past the current reveal cursor
133134
/// (fresh recovery, gap deposits, etc.). On a completely fresh wallet `last_revealed` is
134-
/// `None`, so the window is simply `0..stop_gap`.
135+
/// `None`, so the full window is simply `0..stop_gap`.
135136
///
136-
/// The accompanying map lets callers translate a matched output script back to
137+
/// `start_indices` lets callers restrict the returned scripts to a tail of the window
138+
/// per keychain. Indices strictly less than `start_indices[keychain]` are skipped, so
139+
/// callers running a multi-pass recovery loop can scan only the *new* scripts past a
140+
/// previously scanned boundary. A keychain absent from the map starts at index 0.
141+
///
142+
/// The returned `window_ends` map records the exclusive upper bound used for each
143+
/// keychain. Callers can feed this back as the next iteration's `start_indices` to
144+
/// continue scanning past already-covered indices.
145+
///
146+
/// The script-to-keychain map lets callers translate a matched output script back to
137147
/// `(keychain, index)` so they can populate `Update.last_active_indices` and advance
138148
/// BDK's reveal cursor to reflect what was actually observed on-chain.
139149
pub(crate) fn get_spks_for_cbf_sync(
140-
&self, stop_gap: usize,
141-
) -> (Vec<ScriptBuf>, HashMap<ScriptBuf, (KeychainKind, u32)>) {
150+
&self, stop_gap: usize, start_indices: &BTreeMap<KeychainKind, u32>,
151+
) -> (Vec<ScriptBuf>, HashMap<ScriptBuf, (KeychainKind, u32)>, BTreeMap<KeychainKind, u32>) {
142152
let wallet = self.inner.lock().unwrap();
143153
let mut scripts = Vec::new();
144154
let mut spk_to_keychain_idx: HashMap<ScriptBuf, (KeychainKind, u32)> = HashMap::new();
155+
let mut window_ends: BTreeMap<KeychainKind, u32> = BTreeMap::new();
145156
for keychain in [KeychainKind::External, KeychainKind::Internal] {
146157
let window_end =
147158
wallet.spk_index().last_revealed_index(keychain).map(|i| i + 1).unwrap_or(0)
148159
+ stop_gap as u32;
149-
for idx in 0..window_end {
160+
let start = start_indices.get(&keychain).copied().unwrap_or(0);
161+
for idx in start..window_end {
150162
let spk = wallet.peek_address(keychain, idx).address.script_pubkey();
151163
scripts.push(spk.clone());
152164
spk_to_keychain_idx.insert(spk, (keychain, idx));
153165
}
166+
window_ends.insert(keychain, window_end);
154167
}
155-
(scripts, spk_to_keychain_idx)
168+
(scripts, spk_to_keychain_idx, window_ends)
156169
}
157170

158171
pub(crate) fn latest_checkpoint(&self) -> bdk_chain::CheckPoint {

tests/integration_tests_rust.rs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3197,6 +3197,81 @@ async fn onchain_wallet_recovery_cbf_advances_reveal_cursor() {
31973197
recovered_node.stop().unwrap();
31983198
}
31993199

3200+
/// Regression test: a fresh CBF recovery must discover funds at derivation
3201+
/// indices past the initial `BDK_CLIENT_STOP_GAP` window in a single call to
3202+
/// `sync_wallets()`. Without the convergence loop, the first sync only scans
3203+
/// scripts at indices `0..stop_gap`. Subsequent syncs use a `skip_height`
3204+
/// derived from the just-advanced BDK checkpoint, so once the chain has grown
3205+
/// past the funding block by more than `REORG_SAFETY_BLOCKS`, the historical
3206+
/// block holding the deeper funds is never re-evaluated and the recovery
3207+
/// silently misses the funds.
3208+
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
3209+
async fn onchain_wallet_recovery_cbf_deep_stop_gap() {
3210+
let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();
3211+
let chain_source = TestChainSource::Cbf(&bitcoind);
3212+
3213+
let original_config = random_config(true);
3214+
let original_node_entropy = original_config.node_entropy.clone();
3215+
let original_node = setup_node(&chain_source, original_config);
3216+
3217+
// Reveal 40 addresses; fund the ones at indices 19 and 38 so that:
3218+
// - idx 19 sits inside the initial `0..20` recovery window,
3219+
// - idx 38 sits inside the expanded `0..40` window we get after
3220+
// advancing the reveal cursor to 19,
3221+
// requiring at least two sync iterations to converge.
3222+
let mut addrs = Vec::with_capacity(40);
3223+
for _ in 0..40 {
3224+
addrs.push(original_node.onchain_payment().new_address().unwrap());
3225+
}
3226+
let funded_low = addrs[19].clone();
3227+
let funded_high = addrs[38].clone();
3228+
3229+
let premine_amount_sat = 100_000;
3230+
premine_and_distribute_funds(
3231+
&bitcoind.client,
3232+
&electrsd.client,
3233+
vec![funded_low, funded_high],
3234+
Amount::from_sat(premine_amount_sat),
3235+
)
3236+
.await;
3237+
3238+
// Mine extra blocks so the funding block is well past `REORG_SAFETY_BLOCKS`
3239+
// behind the chain tip. Without this gap a broken recovery could still find
3240+
// the deeper funds on a follow-up sync because the second-sync `skip_height`
3241+
// would not yet exclude the funding block.
3242+
generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 20).await;
3243+
3244+
wait_for_cbf_sync(&original_node, || {
3245+
original_node.list_balances().spendable_onchain_balance_sats == premine_amount_sat * 2
3246+
})
3247+
.await;
3248+
assert_eq!(
3249+
original_node.list_balances().spendable_onchain_balance_sats,
3250+
premine_amount_sat * 2
3251+
);
3252+
3253+
original_node.stop().unwrap();
3254+
drop(original_node);
3255+
3256+
// Recover from a completely fresh wallet state, same seed.
3257+
let mut recovered_config = random_config(true);
3258+
recovered_config.node_entropy = original_node_entropy;
3259+
recovered_config.recovery_mode = true;
3260+
let recovered_node = setup_node(&chain_source, recovered_config);
3261+
3262+
wait_for_cbf_sync(&recovered_node, || {
3263+
recovered_node.list_balances().spendable_onchain_balance_sats == premine_amount_sat * 2
3264+
})
3265+
.await;
3266+
assert_eq!(
3267+
recovered_node.list_balances().spendable_onchain_balance_sats,
3268+
premine_amount_sat * 2,
3269+
"recovery did not find funds beyond the initial CBF stop-gap window"
3270+
);
3271+
3272+
recovered_node.stop().unwrap();
3273+
}
3274+
32003275
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
32013276
async fn onchain_send_receive_cbf() {
32023277
let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();

0 commit comments

Comments
 (0)