Skip to content

Commit 11ce046

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 11ce046

3 files changed

Lines changed: 102 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: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3197,6 +3197,77 @@ 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` window. Without a wider
3202+
/// initial scan on fresh wallets, the first sync only covers `0..stop_gap`
3203+
/// scripts, and once BDK's checkpoint advances past the funding block the
3204+
/// derived `skip_height` excludes it from any subsequent scans — so funds
3205+
/// beyond idx 20 are silently missed.
3206+
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
3207+
async fn onchain_wallet_recovery_cbf_deep_stop_gap() {
3208+
let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();
3209+
let chain_source = TestChainSource::Cbf(&bitcoind);
3210+
3211+
let original_config = random_config(true);
3212+
let original_node_entropy = original_config.node_entropy.clone();
3213+
let original_node = setup_node(&chain_source, original_config);
3214+
3215+
// Reveal addresses past the steady-state stop-gap (20), then fund one
3216+
// inside the initial window and another well beyond it. A fresh recovery
3217+
// only finds the deeper one if its first sync uses a wider stop-gap.
3218+
let mut addrs = Vec::with_capacity(40);
3219+
for _ in 0..40 {
3220+
addrs.push(original_node.onchain_payment().new_address().unwrap());
3221+
}
3222+
let funded_low = addrs[19].clone();
3223+
let funded_high = addrs[38].clone();
3224+
3225+
let premine_amount_sat = 100_000;
3226+
premine_and_distribute_funds(
3227+
&bitcoind.client,
3228+
&electrsd.client,
3229+
vec![funded_low, funded_high],
3230+
Amount::from_sat(premine_amount_sat),
3231+
)
3232+
.await;
3233+
3234+
// Mine extra blocks so the funding block sits more than `REORG_SAFETY_BLOCKS`
3235+
// behind the chain tip. Without this gap a broken recovery could still find
3236+
// the deeper funds on a follow-up sync because the second-sync `skip_height`
3237+
// would not yet exclude the funding block.
3238+
generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 20).await;
3239+
3240+
wait_for_cbf_sync(&original_node, || {
3241+
original_node.list_balances().spendable_onchain_balance_sats == premine_amount_sat * 2
3242+
})
3243+
.await;
3244+
assert_eq!(
3245+
original_node.list_balances().spendable_onchain_balance_sats,
3246+
premine_amount_sat * 2
3247+
);
3248+
3249+
original_node.stop().unwrap();
3250+
drop(original_node);
3251+
3252+
// Recover from a completely fresh wallet state, same seed.
3253+
let mut recovered_config = random_config(true);
3254+
recovered_config.node_entropy = original_node_entropy;
3255+
recovered_config.recovery_mode = true;
3256+
let recovered_node = setup_node(&chain_source, recovered_config);
3257+
3258+
wait_for_cbf_sync(&recovered_node, || {
3259+
recovered_node.list_balances().spendable_onchain_balance_sats == premine_amount_sat * 2
3260+
})
3261+
.await;
3262+
assert_eq!(
3263+
recovered_node.list_balances().spendable_onchain_balance_sats,
3264+
premine_amount_sat * 2,
3265+
"recovery did not find funds beyond the initial CBF stop-gap window"
3266+
);
3267+
3268+
recovered_node.stop().unwrap();
3269+
}
3270+
32003271
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
32013272
async fn onchain_send_receive_cbf() {
32023273
let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();

0 commit comments

Comments
 (0)