Skip to content

Commit 2533c29

Browse files
committed
fix(cbf): widen stop-gap to 200 on fresh-wallet recovery
Without a wider initial window, a fresh-wallet recovery only scans scripts at indices 0..20 and silently misses any funds past the lookahead. A follow-up sync can't rescue them either, because BDK's checkpoint advances past the funding block after the first pass and `skip_height` excludes it from every subsequent scan.
1 parent 303deaa commit 2533c29

3 files changed

Lines changed: 91 additions & 3 deletions

File tree

src/chain/cbf.rs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,13 @@ 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+
/// Stop-gap window size used when syncing a *fresh* on-chain wallet (one whose
59+
/// reveal cursor is still `None` on both keychains). Bigger than the steady-state
60+
/// `BDK_CLIENT_STOP_GAP` because the reveal cursor can't advance across a gap
61+
/// wider than the initial scan window in a single sync, so fresh recoveries need
62+
/// a wider cushion up-front. Subsequent syncs fall back to `BDK_CLIENT_STOP_GAP`.
63+
const FRESH_RECOVERY_STOP_GAP: usize = 200;
64+
5865
/// The fee estimation back-end used by the CBF chain source.
5966
enum FeeSource {
6067
/// Derive fee rates from the coinbase reward of recent blocks.
@@ -587,8 +594,17 @@ impl CbfChainSource {
587594
let res = async {
588595
let requester = self.requester()?;
589596

590-
let (scripts, spk_to_keychain_idx) =
591-
onchain_wallet.get_spks_for_cbf_sync(BDK_CLIENT_STOP_GAP);
597+
// On a fresh wallet (reveal cursor still `None` on every keychain) we
598+
// widen the scan window to `FRESH_RECOVERY_STOP_GAP` so a single sync
599+
// can recover funds across deeper derivation gaps. Once the wallet has
600+
// any revealed activity we drop back to the steady-state stop-gap.
601+
let stop_gap = if onchain_wallet.is_fresh_for_cbf_sync() {
602+
FRESH_RECOVERY_STOP_GAP
603+
} else {
604+
BDK_CLIENT_STOP_GAP
605+
};
606+
607+
let (scripts, spk_to_keychain_idx) = onchain_wallet.get_spks_for_cbf_sync(stop_gap);
592608
if scripts.is_empty() {
593609
log_debug!(self.logger, "No wallet scripts to sync via CBF.");
594610
} else {

src/wallet/mod.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,10 @@ impl Wallet {
131131
/// last revealed index. This mirrors BDK's internal `KeychainTxOutIndex` lookahead so
132132
/// CBF also scans for funds that land at indices just past the current reveal cursor
133133
/// (fresh recovery, gap deposits, etc.). On a completely fresh wallet `last_revealed` is
134-
/// `None`, so the window is simply `0..stop_gap`.
134+
/// `None`, so the window is simply `0..stop_gap`. Callers should pass a wider
135+
/// `stop_gap` (see [`Self::is_fresh_for_cbf_sync`]) when they need a single sync to
136+
/// recover funds that may sit past the steady-state lookahead, since BDK's reveal
137+
/// cursor cannot advance further than the scan window in a single pass.
135138
///
136139
/// The accompanying map lets callers translate a matched output script back to
137140
/// `(keychain, index)` so they can populate `Update.last_active_indices` and advance
@@ -155,6 +158,15 @@ impl Wallet {
155158
(scripts, spk_to_keychain_idx)
156159
}
157160

161+
/// Returns `true` when the on-chain wallet has not yet revealed any address on
162+
/// either keychain, i.e. when a CBF sync should treat this as a fresh recovery
163+
/// and use a wider initial scan window.
164+
pub(crate) fn is_fresh_for_cbf_sync(&self) -> bool {
165+
let wallet = self.inner.lock().unwrap();
166+
wallet.spk_index().last_revealed_index(KeychainKind::External).is_none()
167+
&& wallet.spk_index().last_revealed_index(KeychainKind::Internal).is_none()
168+
}
169+
158170
pub(crate) fn latest_checkpoint(&self) -> bdk_chain::CheckPoint {
159171
self.inner.lock().unwrap().latest_checkpoint()
160172
}

tests/integration_tests_rust.rs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3197,6 +3197,66 @@ 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 steady-state `BDK_CLIENT_STOP_GAP` (20). Without a wider
3202+
/// initial window on fresh wallets, the first sync only covers `0..20` and
3203+
/// `last_revealed` stays `None`, so the window never expands and the deeper
3204+
/// funds are silently lost.
3205+
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
3206+
async fn onchain_wallet_recovery_cbf_deep_stop_gap() {
3207+
let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();
3208+
let chain_source = TestChainSource::Cbf(&bitcoind);
3209+
3210+
let original_config = random_config(true);
3211+
let original_node_entropy = original_config.node_entropy.clone();
3212+
let original_node = setup_node(&chain_source, original_config);
3213+
3214+
// Reveal 30 addresses and fund only the one at idx 25. Index 25 sits past
3215+
// the steady-state stop-gap (20) but well within the fresh-recovery window
3216+
// (200), so a fresh recovery only finds it when the fix is in place.
3217+
let mut addrs = Vec::with_capacity(30);
3218+
for _ in 0..30 {
3219+
addrs.push(original_node.onchain_payment().new_address().unwrap());
3220+
}
3221+
let funded = addrs[25].clone();
3222+
3223+
let premine_amount_sat = 100_000;
3224+
premine_and_distribute_funds(
3225+
&bitcoind.client,
3226+
&electrsd.client,
3227+
vec![funded],
3228+
Amount::from_sat(premine_amount_sat),
3229+
)
3230+
.await;
3231+
3232+
wait_for_cbf_sync(&original_node, || {
3233+
original_node.list_balances().spendable_onchain_balance_sats == premine_amount_sat
3234+
})
3235+
.await;
3236+
assert_eq!(original_node.list_balances().spendable_onchain_balance_sats, premine_amount_sat);
3237+
3238+
original_node.stop().unwrap();
3239+
drop(original_node);
3240+
3241+
// Recover from a completely fresh wallet state, same seed.
3242+
let mut recovered_config = random_config(true);
3243+
recovered_config.node_entropy = original_node_entropy;
3244+
recovered_config.recovery_mode = true;
3245+
let recovered_node = setup_node(&chain_source, recovered_config);
3246+
3247+
wait_for_cbf_sync(&recovered_node, || {
3248+
recovered_node.list_balances().spendable_onchain_balance_sats == premine_amount_sat
3249+
})
3250+
.await;
3251+
assert_eq!(
3252+
recovered_node.list_balances().spendable_onchain_balance_sats,
3253+
premine_amount_sat,
3254+
"recovery did not find funds beyond the initial CBF stop-gap window"
3255+
);
3256+
3257+
recovered_node.stop().unwrap();
3258+
}
3259+
32003260
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
32013261
async fn onchain_send_receive_cbf() {
32023262
let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();

0 commit comments

Comments
 (0)