Skip to content

Commit aec47c5

Browse files
committed
Configure Esplora full-scan stop gap
Expose the BDK full-scan stop gap on Esplora sync config so wallet recovery can scan past the previous fixed gap when needed. Clamp out-of-range values at full-scan time and warn about the effective value, keeping the default behavior unchanged. Co-Authored-By: HAL 9000
1 parent f2e44fd commit aec47c5

5 files changed

Lines changed: 156 additions & 9 deletions

File tree

src/chain/esplora.rs

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,16 @@ use lightning::util::ser::Writeable;
1818
use lightning_transaction_sync::EsploraSyncClient;
1919

2020
use super::WalletSyncStatus;
21-
use crate::config::{Config, EsploraSyncConfig, BDK_CLIENT_CONCURRENCY, BDK_CLIENT_STOP_GAP};
21+
use crate::config::{
22+
clamp_full_scan_stop_gap, Config, EsploraSyncConfig, BDK_CLIENT_CONCURRENCY,
23+
MAX_FULL_SCAN_STOP_GAP, MIN_FULL_SCAN_STOP_GAP,
24+
};
2225
use crate::fee_estimator::{
2326
apply_post_estimation_adjustments, get_all_conf_targets, get_num_block_defaults_for_target,
2427
OnchainFeeEstimator,
2528
};
2629
use crate::io::utils::update_and_persist_node_metrics;
27-
use crate::logger::{log_bytes, log_debug, log_error, log_trace, LdkLogger, Logger};
30+
use crate::logger::{log_bytes, log_debug, log_error, log_trace, log_warn, LdkLogger, Logger};
2831
use crate::types::{ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet};
2932
use crate::{Error, PersistedNodeMetrics};
3033

@@ -194,13 +197,14 @@ impl EsploraChainSource {
194197
get_and_apply_wallet_update!(wallet_sync_timeout_fut)
195198
} else {
196199
let full_scan_request = onchain_wallet.get_full_scan_request();
200+
let full_scan_stop_gap = self.bounded_full_scan_stop_gap();
197201
let wallet_sync_timeout_fut = tokio::time::timeout(
198202
Duration::from_secs(
199203
self.sync_config.timeouts_config.onchain_wallet_sync_timeout_secs,
200204
),
201205
self.esplora_client.full_scan(
202206
full_scan_request,
203-
BDK_CLIENT_STOP_GAP,
207+
full_scan_stop_gap,
204208
BDK_CLIENT_CONCURRENCY,
205209
),
206210
);
@@ -212,6 +216,22 @@ impl EsploraChainSource {
212216
res
213217
}
214218

219+
fn bounded_full_scan_stop_gap(&self) -> usize {
220+
let configured = self.sync_config.full_scan_stop_gap;
221+
let bounded = clamp_full_scan_stop_gap(configured);
222+
if bounded != configured {
223+
log_warn!(
224+
self.logger,
225+
"Configured Esplora on-chain wallet full-scan stop gap {} is outside the allowed range {}..={}; using {}.",
226+
configured,
227+
MIN_FULL_SCAN_STOP_GAP,
228+
MAX_FULL_SCAN_STOP_GAP,
229+
bounded
230+
);
231+
}
232+
bounded as usize
233+
}
234+
215235
pub(super) async fn sync_lightning_wallet(
216236
&self, channel_manager: Arc<ChannelManager>, chain_monitor: Arc<ChainMonitor>,
217237
output_sweeper: Arc<Sweeper>,

src/config.rs

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,23 @@ pub const DEFAULT_STORAGE_DIR_PATH: &str = "/tmp/ldk_node";
5757
// The default Esplora server we're using.
5858
pub(crate) const DEFAULT_ESPLORA_SERVER_URL: &str = "https://blockstream.info/api";
5959

60-
// The 'stop gap' parameter used by BDK's wallet sync. This seems to configure the threshold
61-
// number of derivation indexes after which BDK stops looking for new scripts belonging to the wallet.
62-
pub(crate) const BDK_CLIENT_STOP_GAP: usize = 20;
60+
/// The default stop gap used for BDK full scans of the on-chain wallet.
61+
///
62+
/// The current default is 20.
63+
pub const DEFAULT_FULL_SCAN_STOP_GAP: u32 = 20;
64+
65+
/// The minimum allowed stop gap used for BDK full scans of the on-chain wallet.
66+
///
67+
/// Values below 1 are clamped to 1 when a full scan runs.
68+
pub const MIN_FULL_SCAN_STOP_GAP: u32 = 1;
69+
70+
/// The maximum allowed stop gap used for BDK full scans of the on-chain wallet.
71+
///
72+
/// Values above 1000 are clamped to 1000 when a full scan runs.
73+
pub const MAX_FULL_SCAN_STOP_GAP: u32 = 1000;
74+
75+
// The fixed stop gap used by backends that don't yet expose a configurable value.
76+
pub(crate) const BDK_CLIENT_STOP_GAP: usize = DEFAULT_FULL_SCAN_STOP_GAP as usize;
6377

6478
// The number of concurrent requests made against the API provider.
6579
pub(crate) const BDK_CLIENT_CONCURRENCY: usize = 4;
@@ -506,6 +520,23 @@ pub struct EsploraSyncConfig {
506520
pub background_sync_config: Option<BackgroundSyncConfig>,
507521
/// Sync timeouts configuration.
508522
pub timeouts_config: SyncTimeoutsConfig,
523+
/// The stop gap used for BDK full scans of the on-chain wallet.
524+
///
525+
/// A full scan for each keychain stops after this many consecutive script pubkeys
526+
/// with no associated transactions. This value is only used for BDK `full_scan`
527+
/// calls, which ldk-node performs on the first on-chain wallet sync or when
528+
/// [`Self::force_wallet_full_scan`] is set. Incremental BDK `sync` calls do not use it.
529+
///
530+
/// **Default:** 20 ([`DEFAULT_FULL_SCAN_STOP_GAP`])
531+
///
532+
/// **Allowed values:** 1 ([`MIN_FULL_SCAN_STOP_GAP`]) to 1000
533+
/// ([`MAX_FULL_SCAN_STOP_GAP`]), inclusive. Values outside this range will be clamped to the
534+
/// nearest bound and a warning will be logged when the full scan runs.
535+
///
536+
/// **Note:** Large values can cause many Esplora requests, hit server rate limits,
537+
/// take a long time to complete, or cause syncs to fail with
538+
/// [`SyncTimeoutsConfig::onchain_wallet_sync_timeout_secs`].
539+
pub full_scan_stop_gap: u32,
509540
/// Whether to force BDK full scans until one succeeds.
510541
///
511542
/// This can be useful when restoring a wallet from seed on a node that has already synced
@@ -518,6 +549,7 @@ impl Default for EsploraSyncConfig {
518549
Self {
519550
background_sync_config: Some(BackgroundSyncConfig::default()),
520551
timeouts_config: SyncTimeoutsConfig::default(),
552+
full_scan_stop_gap: DEFAULT_FULL_SCAN_STOP_GAP,
521553
force_wallet_full_scan: false,
522554
}
523555
}
@@ -556,6 +588,10 @@ impl Default for ElectrumSyncConfig {
556588
}
557589
}
558590

591+
pub(crate) fn clamp_full_scan_stop_gap(full_scan_stop_gap: u32) -> u32 {
592+
full_scan_stop_gap.clamp(MIN_FULL_SCAN_STOP_GAP, MAX_FULL_SCAN_STOP_GAP)
593+
}
594+
559595
/// Configuration for syncing with Bitcoin Core backend via REST.
560596
#[derive(Debug, Clone)]
561597
pub struct BitcoindRestClientConfig {
@@ -711,7 +747,11 @@ pub enum AsyncPaymentsRole {
711747
mod tests {
712748
use std::str::FromStr;
713749

714-
use super::{may_announce_channel, AnnounceError, Config, NodeAlias, SocketAddress};
750+
use super::{
751+
clamp_full_scan_stop_gap, may_announce_channel, AnnounceError, Config, EsploraSyncConfig,
752+
NodeAlias, SocketAddress, DEFAULT_FULL_SCAN_STOP_GAP, MAX_FULL_SCAN_STOP_GAP,
753+
MIN_FULL_SCAN_STOP_GAP,
754+
};
715755

716756
#[test]
717757
fn node_announce_channel() {
@@ -758,4 +798,21 @@ mod tests {
758798
}
759799
assert!(may_announce_channel(&node_config).is_ok());
760800
}
801+
802+
#[test]
803+
fn full_scan_stop_gap_defaults() {
804+
assert_eq!(EsploraSyncConfig::default().full_scan_stop_gap, DEFAULT_FULL_SCAN_STOP_GAP);
805+
}
806+
807+
#[test]
808+
fn full_scan_stop_gap_is_clamped_to_valid_range() {
809+
assert_eq!(clamp_full_scan_stop_gap(MIN_FULL_SCAN_STOP_GAP), MIN_FULL_SCAN_STOP_GAP);
810+
assert_eq!(
811+
clamp_full_scan_stop_gap(DEFAULT_FULL_SCAN_STOP_GAP),
812+
DEFAULT_FULL_SCAN_STOP_GAP
813+
);
814+
assert_eq!(clamp_full_scan_stop_gap(MAX_FULL_SCAN_STOP_GAP), MAX_FULL_SCAN_STOP_GAP);
815+
assert_eq!(clamp_full_scan_stop_gap(0), MIN_FULL_SCAN_STOP_GAP);
816+
assert_eq!(clamp_full_scan_stop_gap(MAX_FULL_SCAN_STOP_GAP + 1), MAX_FULL_SCAN_STOP_GAP);
817+
}
761818
}

src/logger.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ use lightning::ln::types::ChannelId;
1919
use lightning::types::payment::PaymentHash;
2020
pub use lightning::util::logger::Level as LogLevel;
2121
pub(crate) use lightning::util::logger::{Logger as LdkLogger, Record as LdkRecord};
22-
pub(crate) use lightning::{log_bytes, log_debug, log_error, log_info, log_trace};
22+
pub(crate) use lightning::{log_bytes, log_debug, log_error, log_info, log_trace, log_warn};
2323
use log::{Level as LogFacadeLevel, Record as LogFacadeRecord};
2424

2525
/// A unit of logging output with metadata to enable filtering `module_path`,

tests/common/mod.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,7 @@ pub(crate) struct TestConfig {
437437
pub async_payments_role: Option<AsyncPaymentsRole>,
438438
pub wallet_rescan_from_height: Option<u32>,
439439
pub force_wallet_full_scan: bool,
440+
pub full_scan_stop_gap: Option<u32>,
440441
}
441442

442443
impl Default for TestConfig {
@@ -450,6 +451,7 @@ impl Default for TestConfig {
450451
let async_payments_role = None;
451452
let wallet_rescan_from_height = None;
452453
let force_wallet_full_scan = false;
454+
let full_scan_stop_gap = None;
453455
TestConfig {
454456
node_config,
455457
log_writer,
@@ -458,6 +460,7 @@ impl Default for TestConfig {
458460
async_payments_role,
459461
wallet_rescan_from_height,
460462
force_wallet_full_scan,
463+
full_scan_stop_gap,
461464
}
462465
}
463466
}
@@ -541,6 +544,9 @@ pub(crate) fn setup_node(chain_source: &TestChainSource, config: TestConfig) ->
541544
let mut sync_config = EsploraSyncConfig::default();
542545
sync_config.background_sync_config = None;
543546
sync_config.force_wallet_full_scan = config.force_wallet_full_scan;
547+
if let Some(full_scan_stop_gap) = config.full_scan_stop_gap {
548+
sync_config.full_scan_stop_gap = full_scan_stop_gap;
549+
}
544550
builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config));
545551
},
546552
TestChainSource::Electrum(electrsd) => {

tests/integration_tests_rust.rs

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ use common::{
2929
};
3030
use electrsd::corepc_node::{self, Node as BitcoinD};
3131
use electrsd::ElectrsD;
32-
use ldk_node::config::{AsyncPaymentsRole, EsploraSyncConfig};
32+
use ldk_node::config::{AsyncPaymentsRole, EsploraSyncConfig, DEFAULT_FULL_SCAN_STOP_GAP};
3333
use ldk_node::entropy::NodeEntropy;
3434
use ldk_node::liquidity::LSPS2ServiceConfig;
3535
use ldk_node::payment::{
@@ -999,6 +999,70 @@ async fn onchain_wallet_force_full_scan_rediscovers_esplora_funds() {
999999
);
10001000
}
10011001

1002+
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
1003+
async fn onchain_wallet_full_scan_stop_gap_recovers_far_esplora_funds() {
1004+
let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();
1005+
premine_blocks(&bitcoind.client, &electrsd.client).await;
1006+
1007+
do_onchain_wallet_full_scan_stop_gap_recovers_far_funds(
1008+
TestChainSource::Esplora(&electrsd),
1009+
&bitcoind,
1010+
&electrsd,
1011+
)
1012+
.await;
1013+
}
1014+
1015+
async fn do_onchain_wallet_full_scan_stop_gap_recovers_far_funds(
1016+
chain_source: TestChainSource<'_>, bitcoind: &BitcoinD, electrsd: &ElectrsD,
1017+
) {
1018+
let configured_stop_gap = DEFAULT_FULL_SCAN_STOP_GAP + 5;
1019+
1020+
let address_source_config = random_config(true);
1021+
let node_entropy = address_source_config.node_entropy;
1022+
let address_source_node = setup_node(&chain_source, address_source_config);
1023+
let mut far_address = None;
1024+
for _ in 0..configured_stop_gap {
1025+
far_address = Some(address_source_node.onchain_payment().new_address().unwrap());
1026+
}
1027+
address_source_node.stop().unwrap();
1028+
drop(address_source_node);
1029+
let far_address = far_address.unwrap();
1030+
1031+
let premine_amount_sat = 100_000;
1032+
let txid = bitcoind
1033+
.client
1034+
.send_to_address(&far_address, Amount::from_sat(premine_amount_sat))
1035+
.unwrap()
1036+
.0
1037+
.parse()
1038+
.unwrap();
1039+
wait_for_tx(&electrsd.client, txid).await;
1040+
generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await;
1041+
1042+
let mut default_gap_config = random_config(true);
1043+
default_gap_config.node_entropy = node_entropy.clone();
1044+
let default_gap_node = setup_node(&chain_source, default_gap_config);
1045+
default_gap_node.sync_wallets().unwrap();
1046+
assert_eq!(
1047+
default_gap_node.list_balances().spendable_onchain_balance_sats,
1048+
0,
1049+
"default full-scan stop gap should not recover funds past its address gap"
1050+
);
1051+
default_gap_node.stop().unwrap();
1052+
drop(default_gap_node);
1053+
1054+
let mut configured_gap_config = random_config(true);
1055+
configured_gap_config.node_entropy = node_entropy;
1056+
configured_gap_config.full_scan_stop_gap = Some(configured_stop_gap);
1057+
let configured_gap_node = setup_node(&chain_source, configured_gap_config);
1058+
configured_gap_node.sync_wallets().unwrap();
1059+
assert_eq!(
1060+
configured_gap_node.list_balances().spendable_onchain_balance_sats,
1061+
premine_amount_sat,
1062+
"configured full-scan stop gap should recover funds past the default address gap"
1063+
);
1064+
}
1065+
10021066
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
10031067
async fn onchain_wallet_recovery_rescans_from_birthday_height() {
10041068
// End-to-end test for `wallet_rescan_from_height` against a bitcoind chain source. The

0 commit comments

Comments
 (0)