Skip to content

Commit f69b1a1

Browse files
committed
fix(cbf): advance reveal cursor from matched outputs on sync
Walk matched tx outputs to populate Update.last_active_indices and widen the scan window to cover a lookahead past the last revealed index.
1 parent a600200 commit f69b1a1

3 files changed

Lines changed: 117 additions & 17 deletions

File tree

src/chain/cbf.rs

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use std::sync::{Arc, Mutex, RwLock};
1212
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
1313

1414
use bdk_chain::{BlockId, ConfirmationBlockTime, TxUpdate};
15-
use bdk_wallet::Update;
15+
use bdk_wallet::{KeychainKind, Update};
1616
use bip157::chain::{BlockHeaderChanges, ChainState};
1717
use bip157::{
1818
BlockHash, Builder, Client, Event, HeaderCheckpoint, Info, Node as CbfNode, Requester,
@@ -587,7 +587,8 @@ impl CbfChainSource {
587587
let res = async {
588588
let requester = self.requester()?;
589589

590-
let scripts = onchain_wallet.get_spks_for_cbf_sync(BDK_CLIENT_STOP_GAP);
590+
let (scripts, spk_to_keychain_idx) =
591+
onchain_wallet.get_spks_for_cbf_sync(BDK_CLIENT_STOP_GAP);
591592
if scripts.is_empty() {
592593
log_debug!(self.logger, "No wallet scripts to sync via CBF.");
593594
} else {
@@ -621,8 +622,25 @@ impl CbfChainSource {
621622
cp = cp.push(tip_block_id).unwrap_or_else(|old| old);
622623
}
623624

624-
let update =
625-
Update { last_active_indices: BTreeMap::new(), tx_update, chain: Some(cp) };
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.
629+
let mut last_active_indices: BTreeMap<KeychainKind, u32> = BTreeMap::new();
630+
for tx in &tx_update.txs {
631+
for txout in &tx.output {
632+
if let Some(&(keychain, idx)) =
633+
spk_to_keychain_idx.get(&txout.script_pubkey)
634+
{
635+
last_active_indices
636+
.entry(keychain)
637+
.and_modify(|cur| *cur = (*cur).max(idx))
638+
.or_insert(idx);
639+
}
640+
}
641+
}
642+
643+
let update = Update { last_active_indices, tx_update, chain: Some(cp) };
626644

627645
onchain_wallet.apply_update(update)?;
628646

src/wallet/mod.rs

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +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;
89
use std::future::Future;
910
use std::ops::Deref;
1011
use std::str::FromStr;
@@ -122,22 +123,36 @@ impl Wallet {
122123
self.inner.lock().unwrap().start_sync_with_revealed_spks().build()
123124
}
124125

125-
pub(crate) fn get_spks_for_cbf_sync(&self, stop_gap: usize) -> Vec<ScriptBuf> {
126+
/// Returns the on-chain scripts CBF should scan for, plus a mapping
127+
/// from each script to its `(keychain, derivation index)`.
128+
///
129+
/// For each keychain, the returned set covers indices `0..last_revealed + 1 + stop_gap`,
130+
/// i.e. all already-revealed scripts plus a `stop_gap`-sized lookahead buffer past the
131+
/// last revealed index. This mirrors BDK's internal `KeychainTxOutIndex` lookahead so
132+
/// CBF also scans for funds that land at indices just past the current reveal cursor
133+
/// (fresh recovery, gap deposits, etc.). On a completely fresh wallet `last_revealed` is
134+
/// `None`, so the window is simply `0..stop_gap`.
135+
///
136+
/// The accompanying map lets callers translate a matched output script back to
137+
/// `(keychain, index)` so they can populate `Update.last_active_indices` and advance
138+
/// BDK's reveal cursor to reflect what was actually observed on-chain.
139+
pub(crate) fn get_spks_for_cbf_sync(
140+
&self, stop_gap: usize,
141+
) -> (Vec<ScriptBuf>, HashMap<ScriptBuf, (KeychainKind, u32)>) {
126142
let wallet = self.inner.lock().unwrap();
127-
let mut scripts: Vec<ScriptBuf> =
128-
wallet.spk_index().revealed_spks(..).map(|((_, _), spk)| spk).collect();
129-
130-
// For first sync when no scripts have been revealed yet, generate
131-
// lookahead scripts up to the stop gap for both keychains.
132-
if scripts.is_empty() {
133-
for keychain in [KeychainKind::External, KeychainKind::Internal] {
134-
for idx in 0..stop_gap as u32 {
135-
scripts.push(wallet.peek_address(keychain, idx).address.script_pubkey());
136-
}
143+
let mut scripts = Vec::new();
144+
let mut spk_to_keychain_idx: HashMap<ScriptBuf, (KeychainKind, u32)> = HashMap::new();
145+
for keychain in [KeychainKind::External, KeychainKind::Internal] {
146+
let window_end =
147+
wallet.spk_index().last_revealed_index(keychain).map(|i| i + 1).unwrap_or(0)
148+
+ stop_gap as u32;
149+
for idx in 0..window_end {
150+
let spk = wallet.peek_address(keychain, idx).address.script_pubkey();
151+
scripts.push(spk.clone());
152+
spk_to_keychain_idx.insert(spk, (keychain, idx));
137153
}
138154
}
139-
140-
scripts
155+
(scripts, spk_to_keychain_idx)
141156
}
142157

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

tests/integration_tests_rust.rs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3130,6 +3130,73 @@ async fn onchain_wallet_recovery_cbf() {
31303130
recovered_node.stop().unwrap();
31313131
}
31323132

3133+
/// Regression test: after a CBF recovery sync, BDK's reveal cursor should have
3134+
/// advanced past every derivation index we observed on-chain. Otherwise a
3135+
/// freshly recovered node would hand out an already-used address on the next
3136+
/// `new_address` call. Before the fix that populates `last_active_indices` from
3137+
/// matched CBF outputs, the reveal cursor stayed at `None` on recovery because
3138+
/// BDK's internal lookahead absorbed the tx without advancing the public cursor.
3139+
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
3140+
async fn onchain_wallet_recovery_cbf_advances_reveal_cursor() {
3141+
let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();
3142+
let chain_source = TestChainSource::Cbf(&bitcoind);
3143+
3144+
let original_config = random_config(true);
3145+
let original_node_entropy = original_config.node_entropy.clone();
3146+
let original_node = setup_node(&chain_source, original_config);
3147+
3148+
// Reveal several addresses and deposit onto the last one, so the highest
3149+
// on-chain index is strictly greater than zero.
3150+
let mut funded_addr = None;
3151+
for _ in 0..5 {
3152+
funded_addr = Some(original_node.onchain_payment().new_address().unwrap());
3153+
}
3154+
let funded_addr = funded_addr.unwrap();
3155+
3156+
let premine_amount_sat = 100_000;
3157+
premine_and_distribute_funds(
3158+
&bitcoind.client,
3159+
&electrsd.client,
3160+
vec![funded_addr],
3161+
Amount::from_sat(premine_amount_sat),
3162+
)
3163+
.await;
3164+
3165+
wait_for_cbf_sync(&original_node, || {
3166+
original_node.list_balances().spendable_onchain_balance_sats == premine_amount_sat
3167+
})
3168+
.await;
3169+
3170+
// The address the original node would hand out next, i.e. the descriptor's
3171+
// derivation index immediately after the funded one. Recovery should match this.
3172+
let expected_next_addr = original_node.onchain_payment().new_address().unwrap();
3173+
3174+
original_node.stop().unwrap();
3175+
drop(original_node);
3176+
3177+
// Recover from a completely fresh wallet state, same seed.
3178+
let mut recovered_config = random_config(true);
3179+
recovered_config.node_entropy = original_node_entropy;
3180+
recovered_config.recovery_mode = true;
3181+
let recovered_node = setup_node(&chain_source, recovered_config);
3182+
3183+
wait_for_cbf_sync(&recovered_node, || {
3184+
recovered_node.list_balances().spendable_onchain_balance_sats == premine_amount_sat
3185+
})
3186+
.await;
3187+
3188+
// After recovery, the next address the node hands out must reflect the
3189+
// highest index that appeared on-chain. If the reveal cursor did not
3190+
// advance, this would silently reuse the first-ever address.
3191+
let recovered_next_addr = recovered_node.onchain_payment().new_address().unwrap();
3192+
assert_eq!(
3193+
recovered_next_addr, expected_next_addr,
3194+
"recovery did not advance reveal cursor past observed on-chain indices"
3195+
);
3196+
3197+
recovered_node.stop().unwrap();
3198+
}
3199+
31333200
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
31343201
async fn onchain_send_receive_cbf() {
31353202
let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();

0 commit comments

Comments
 (0)