Skip to content

Commit 101c827

Browse files
committed
Add forced wallet full scans
Let Esplora and Electrum sync configs request BDK full scans until one succeeds. This keeps recovery scans retryable after transient sync failures while preserving normal incremental syncs once recovery has completed. Co-Authored-By: HAL 9000
1 parent 72d2414 commit 101c827

6 files changed

Lines changed: 107 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
wallet on a pruned node where the full history is unavailable but the wallet birthday height is
1414
known. Existing wallets are not rewound, and future heights fail the build. Passing `Some(0)`
1515
rescans from genesis; passing `None` keeps the default current-tip checkpoint behavior. (#884)
16+
- `EsploraSyncConfig` and `ElectrumSyncConfig` now support `force_wallet_full_scan`. When set,
17+
the on-chain wallet keeps using BDK `full_scan` instead of incremental sync until a full scan
18+
succeeds, allowing restored wallets to rediscover funds sent to previously-unknown addresses.
1619

1720
## Bug Fixes and Improvements
1821
- Building a fresh node against a Bitcoin Core RPC or REST chain source that fails to return the

src/chain/electrum.rs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
// accordance with one or both of these licenses.
77

88
use std::collections::HashMap;
9+
use std::sync::atomic::{AtomicBool, Ordering};
910
use std::sync::{Arc, Mutex, RwLock};
1011
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
1112

@@ -50,6 +51,7 @@ pub(super) struct ElectrumChainSource {
5051
config: Arc<Config>,
5152
logger: Arc<Logger>,
5253
node_metrics: Arc<PersistedNodeMetrics>,
54+
force_wallet_full_scan: AtomicBool,
5355
}
5456

5557
impl ElectrumChainSource {
@@ -61,6 +63,7 @@ impl ElectrumChainSource {
6163
let electrum_runtime_status = RwLock::new(ElectrumRuntimeStatus::new());
6264
let onchain_wallet_sync_status = Mutex::new(WalletSyncStatus::Completed);
6365
let lightning_wallet_sync_status = Mutex::new(WalletSyncStatus::Completed);
66+
let force_wallet_full_scan = AtomicBool::new(sync_config.force_wallet_full_scan);
6467
Self {
6568
server_url,
6669
sync_config,
@@ -72,6 +75,7 @@ impl ElectrumChainSource {
7275
config,
7376
logger: Arc::clone(&logger),
7477
node_metrics,
78+
force_wallet_full_scan,
7579
}
7680
}
7781

@@ -125,9 +129,11 @@ impl ElectrumChainSource {
125129
return Err(Error::FeerateEstimationUpdateFailed);
126130
};
127131
// If this is our first sync, do a full scan with the configured gap limit.
128-
// Otherwise just do an incremental sync.
129-
let incremental_sync =
132+
// Otherwise just do an incremental sync, unless a forced full scan is still pending.
133+
let has_prior_sync =
130134
self.node_metrics.read().expect("lock").latest_onchain_wallet_sync_timestamp.is_some();
135+
let forced_full_scan = self.force_wallet_full_scan.load(Ordering::Acquire);
136+
let incremental_sync = has_prior_sync && !forced_full_scan;
131137

132138
let cached_txs = onchain_wallet.get_cached_txs();
133139

@@ -160,6 +166,9 @@ impl ElectrumChainSource {
160166
.await
161167
};
162168

169+
if forced_full_scan && res.is_ok() {
170+
self.force_wallet_full_scan.store(false, Ordering::Release);
171+
}
163172
res
164173
}
165174

src/chain/esplora.rs

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
// accordance with one or both of these licenses.
77

88
use std::collections::HashMap;
9+
use std::sync::atomic::{AtomicBool, Ordering};
910
use std::sync::{Arc, Mutex};
1011
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
1112

@@ -38,6 +39,7 @@ pub(super) struct EsploraChainSource {
3839
config: Arc<Config>,
3940
logger: Arc<Logger>,
4041
node_metrics: Arc<PersistedNodeMetrics>,
42+
force_wallet_full_scan: AtomicBool,
4143
}
4244

4345
impl EsploraChainSource {
@@ -62,6 +64,7 @@ impl EsploraChainSource {
6264

6365
let onchain_wallet_sync_status = Mutex::new(WalletSyncStatus::Completed);
6466
let lightning_wallet_sync_status = Mutex::new(WalletSyncStatus::Completed);
67+
let force_wallet_full_scan = AtomicBool::new(sync_config.force_wallet_full_scan);
6568
Ok(Self {
6669
sync_config,
6770
esplora_client,
@@ -73,6 +76,7 @@ impl EsploraChainSource {
7376
config,
7477
logger,
7578
node_metrics,
79+
force_wallet_full_scan,
7680
})
7781
}
7882

@@ -101,9 +105,11 @@ impl EsploraChainSource {
101105

102106
async fn sync_onchain_wallet_inner(&self, onchain_wallet: Arc<Wallet>) -> Result<(), Error> {
103107
// If this is our first sync, do a full scan with the configured gap limit.
104-
// Otherwise just do an incremental sync.
105-
let incremental_sync =
108+
// Otherwise just do an incremental sync, unless a forced full scan is still pending.
109+
let has_prior_sync =
106110
self.node_metrics.read().expect("lock").latest_onchain_wallet_sync_timestamp.is_some();
111+
let forced_full_scan = self.force_wallet_full_scan.load(Ordering::Acquire);
112+
let incremental_sync = has_prior_sync && !forced_full_scan;
107113

108114
macro_rules! get_and_apply_wallet_update {
109115
($sync_future: expr) => {{
@@ -177,7 +183,7 @@ impl EsploraChainSource {
177183
}}
178184
}
179185

180-
if incremental_sync {
186+
let res = if incremental_sync {
181187
let sync_request = onchain_wallet.get_incremental_sync_request();
182188
let wallet_sync_timeout_fut = tokio::time::timeout(
183189
Duration::from_secs(
@@ -199,7 +205,11 @@ impl EsploraChainSource {
199205
),
200206
);
201207
get_and_apply_wallet_update!(wallet_sync_timeout_fut)
208+
};
209+
if forced_full_scan && res.is_ok() {
210+
self.force_wallet_full_scan.store(false, Ordering::Release);
202211
}
212+
res
203213
}
204214

205215
pub(super) async fn sync_lightning_wallet(

src/config.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,13 +506,19 @@ pub struct EsploraSyncConfig {
506506
pub background_sync_config: Option<BackgroundSyncConfig>,
507507
/// Sync timeouts configuration.
508508
pub timeouts_config: SyncTimeoutsConfig,
509+
/// Whether to force BDK full scans until one succeeds.
510+
///
511+
/// This can be useful when restoring a wallet from seed on a node that has already synced
512+
/// before, but may be missing funds sent to previously-unknown addresses.
513+
pub force_wallet_full_scan: bool,
509514
}
510515

511516
impl Default for EsploraSyncConfig {
512517
fn default() -> Self {
513518
Self {
514519
background_sync_config: Some(BackgroundSyncConfig::default()),
515520
timeouts_config: SyncTimeoutsConfig::default(),
521+
force_wallet_full_scan: false,
516522
}
517523
}
518524
}
@@ -533,13 +539,19 @@ pub struct ElectrumSyncConfig {
533539
pub background_sync_config: Option<BackgroundSyncConfig>,
534540
/// Sync timeouts configuration.
535541
pub timeouts_config: SyncTimeoutsConfig,
542+
/// Whether to force BDK full scans until one succeeds.
543+
///
544+
/// This can be useful when restoring a wallet from seed on a node that has already synced
545+
/// before, but may be missing funds sent to previously-unknown addresses.
546+
pub force_wallet_full_scan: bool,
536547
}
537548

538549
impl Default for ElectrumSyncConfig {
539550
fn default() -> Self {
540551
Self {
541552
background_sync_config: Some(BackgroundSyncConfig::default()),
542553
timeouts_config: SyncTimeoutsConfig::default(),
554+
force_wallet_full_scan: false,
543555
}
544556
}
545557
}

tests/common/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,7 @@ pub(crate) struct TestConfig {
436436
pub node_entropy: NodeEntropy,
437437
pub async_payments_role: Option<AsyncPaymentsRole>,
438438
pub wallet_rescan_from_height: Option<u32>,
439+
pub force_wallet_full_scan: bool,
439440
}
440441

441442
impl Default for TestConfig {
@@ -448,13 +449,15 @@ impl Default for TestConfig {
448449
let node_entropy = NodeEntropy::from_bip39_mnemonic(mnemonic, None);
449450
let async_payments_role = None;
450451
let wallet_rescan_from_height = None;
452+
let force_wallet_full_scan = false;
451453
TestConfig {
452454
node_config,
453455
log_writer,
454456
store_type,
455457
node_entropy,
456458
async_payments_role,
457459
wallet_rescan_from_height,
460+
force_wallet_full_scan,
458461
}
459462
}
460463
}
@@ -537,12 +540,14 @@ pub(crate) fn setup_node(chain_source: &TestChainSource, config: TestConfig) ->
537540
let esplora_url = format!("http://{}", electrsd.esplora_url.as_ref().unwrap());
538541
let mut sync_config = EsploraSyncConfig::default();
539542
sync_config.background_sync_config = None;
543+
sync_config.force_wallet_full_scan = config.force_wallet_full_scan;
540544
builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config));
541545
},
542546
TestChainSource::Electrum(electrsd) => {
543547
let electrum_url = format!("tcp://{}", electrsd.electrum_url);
544548
let mut sync_config = ElectrumSyncConfig::default();
545549
sync_config.background_sync_config = None;
550+
sync_config.force_wallet_full_scan = config.force_wallet_full_scan;
546551
builder.set_chain_source_electrum(electrum_url.clone(), Some(sync_config));
547552
},
548553
TestChainSource::BitcoindRpcSync(bitcoind) => {

tests/integration_tests_rust.rs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -936,6 +936,69 @@ async fn onchain_wallet_recovery() {
936936
);
937937
}
938938

939+
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
940+
async fn onchain_wallet_force_full_scan_rediscovers_esplora_funds() {
941+
let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();
942+
let chain_source = TestChainSource::Esplora(&electrsd);
943+
944+
premine_blocks(&bitcoind.client, &electrsd.client).await;
945+
946+
let address_source_config = random_config(true);
947+
let node_entropy = address_source_config.node_entropy;
948+
let address_source_node = setup_node(&chain_source, address_source_config);
949+
let addr_1 = address_source_node.onchain_payment().new_address().unwrap();
950+
let addr_2 = address_source_node.onchain_payment().new_address().unwrap();
951+
address_source_node.stop().unwrap();
952+
drop(address_source_node);
953+
954+
let premine_amount_sat = 100_000;
955+
let mut stale_config = random_config(true);
956+
stale_config.node_entropy = node_entropy;
957+
stale_config.store_type = TestStoreType::Sqlite;
958+
let stale_node = setup_node(&chain_source, stale_config.clone());
959+
stale_node.sync_wallets().unwrap();
960+
assert_eq!(stale_node.list_balances().spendable_onchain_balance_sats, 0);
961+
stale_node.stop().unwrap();
962+
drop(stale_node);
963+
964+
let txid_1 = bitcoind
965+
.client
966+
.send_to_address(&addr_1, Amount::from_sat(premine_amount_sat))
967+
.unwrap()
968+
.0
969+
.parse()
970+
.unwrap();
971+
wait_for_tx(&electrsd.client, txid_1).await;
972+
let txid_2 = bitcoind
973+
.client
974+
.send_to_address(&addr_2, Amount::from_sat(premine_amount_sat))
975+
.unwrap()
976+
.0
977+
.parse()
978+
.unwrap();
979+
wait_for_tx(&electrsd.client, txid_2).await;
980+
generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await;
981+
982+
let normal_node = setup_node(&chain_source, stale_config.clone());
983+
normal_node.sync_wallets().unwrap();
984+
assert_eq!(
985+
normal_node.list_balances().spendable_onchain_balance_sats,
986+
0,
987+
"normal incremental sync should not rediscover previously-unknown addresses"
988+
);
989+
normal_node.stop().unwrap();
990+
drop(normal_node);
991+
992+
stale_config.force_wallet_full_scan = true;
993+
let recovered_node = setup_node(&chain_source, stale_config);
994+
recovered_node.sync_wallets().unwrap();
995+
assert_eq!(
996+
recovered_node.list_balances().spendable_onchain_balance_sats,
997+
premine_amount_sat * 2,
998+
"forced full scan should rediscover funds sent to previously-unknown addresses"
999+
);
1000+
}
1001+
9391002
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
9401003
async fn onchain_wallet_recovery_rescans_from_birthday_height() {
9411004
// End-to-end test for `wallet_rescan_from_height` against a bitcoind chain source. The

0 commit comments

Comments
 (0)