Skip to content

Commit 1d40ed0

Browse files
authored
Merge pull request #957 from tnull/2026-06-configurable-stop-gap
Make `stop_gap` for Esplora/Electrum configurable
2 parents f2e44fd + da465a4 commit 1d40ed0

6 files changed

Lines changed: 204 additions & 12 deletions

File tree

src/chain/electrum.rs

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,17 @@ use lightning::util::ser::Writeable;
2525
use lightning_transaction_sync::ElectrumSyncClient;
2626

2727
use super::WalletSyncStatus;
28-
use crate::config::{Config, ElectrumSyncConfig, BDK_CLIENT_STOP_GAP};
28+
use crate::config::{
29+
clamp_full_scan_stop_gap, Config, ElectrumSyncConfig, MAX_FULL_SCAN_STOP_GAP,
30+
MIN_FULL_SCAN_STOP_GAP,
31+
};
2932
use crate::error::Error;
3033
use crate::fee_estimator::{
3134
apply_post_estimation_adjustments, get_all_conf_targets, get_num_block_defaults_for_target,
3235
ConfirmationTarget, OnchainFeeEstimator,
3336
};
3437
use crate::io::utils::update_and_persist_node_metrics;
35-
use crate::logger::{log_bytes, log_debug, log_error, log_trace, LdkLogger, Logger};
38+
use crate::logger::{log_bytes, log_debug, log_error, log_trace, log_warn, LdkLogger, Logger};
3639
use crate::runtime::Runtime;
3740
use crate::types::{ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet};
3841
use crate::PersistedNodeMetrics;
@@ -496,11 +499,12 @@ impl ElectrumRuntimeClient {
496499
) -> Result<BdkFullScanResponse<BdkKeyChainKind>, Error> {
497500
let bdk_electrum_client = Arc::clone(&self.bdk_electrum_client);
498501
bdk_electrum_client.populate_tx_cache(cached_txs);
502+
let full_scan_stop_gap = self.bounded_full_scan_stop_gap();
499503

500504
let spawn_fut = self.runtime.spawn_blocking(move || {
501505
bdk_electrum_client.full_scan(
502506
request,
503-
BDK_CLIENT_STOP_GAP,
507+
full_scan_stop_gap,
504508
BDK_ELECTRUM_CLIENT_BATCH_SIZE,
505509
true,
506510
)
@@ -526,6 +530,22 @@ impl ElectrumRuntimeClient {
526530
})
527531
}
528532

533+
fn bounded_full_scan_stop_gap(&self) -> usize {
534+
let configured = self.sync_config.full_scan_stop_gap;
535+
let bounded = clamp_full_scan_stop_gap(configured);
536+
if bounded != configured {
537+
log_warn!(
538+
self.logger,
539+
"Configured Electrum on-chain wallet full-scan stop gap {} is outside the allowed range {}..={}; using {}.",
540+
configured,
541+
MIN_FULL_SCAN_STOP_GAP,
542+
MAX_FULL_SCAN_STOP_GAP,
543+
bounded
544+
);
545+
}
546+
bounded as usize
547+
}
548+
529549
async fn get_incremental_sync_wallet_update(
530550
&self, request: BdkSyncRequest<(BdkKeyChainKind, u32)>,
531551
cached_txs: impl IntoIterator<Item = impl Into<Arc<Transaction>>>,

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: 77 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,20 @@ 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;
6374

6475
// The number of concurrent requests made against the API provider.
6576
pub(crate) const BDK_CLIENT_CONCURRENCY: usize = 4;
@@ -506,6 +517,23 @@ pub struct EsploraSyncConfig {
506517
pub background_sync_config: Option<BackgroundSyncConfig>,
507518
/// Sync timeouts configuration.
508519
pub timeouts_config: SyncTimeoutsConfig,
520+
/// The stop gap used for BDK full scans of the on-chain wallet.
521+
///
522+
/// A full scan for each keychain stops after this many consecutive script pubkeys
523+
/// with no associated transactions. This value is only used for BDK `full_scan`
524+
/// calls, which ldk-node performs on the first on-chain wallet sync or when
525+
/// [`Self::force_wallet_full_scan`] is set. Incremental BDK `sync` calls do not use it.
526+
///
527+
/// **Default:** 20 ([`DEFAULT_FULL_SCAN_STOP_GAP`])
528+
///
529+
/// **Allowed values:** 1 ([`MIN_FULL_SCAN_STOP_GAP`]) to 1000
530+
/// ([`MAX_FULL_SCAN_STOP_GAP`]), inclusive. Values outside this range will be clamped to the
531+
/// nearest bound and a warning will be logged when the full scan runs.
532+
///
533+
/// **Note:** Large values can cause many Esplora requests, hit server rate limits,
534+
/// take a long time to complete, or cause syncs to fail with
535+
/// [`SyncTimeoutsConfig::onchain_wallet_sync_timeout_secs`].
536+
pub full_scan_stop_gap: u32,
509537
/// Whether to force BDK full scans until one succeeds.
510538
///
511539
/// This can be useful when restoring a wallet from seed on a node that has already synced
@@ -518,6 +546,7 @@ impl Default for EsploraSyncConfig {
518546
Self {
519547
background_sync_config: Some(BackgroundSyncConfig::default()),
520548
timeouts_config: SyncTimeoutsConfig::default(),
549+
full_scan_stop_gap: DEFAULT_FULL_SCAN_STOP_GAP,
521550
force_wallet_full_scan: false,
522551
}
523552
}
@@ -539,6 +568,23 @@ pub struct ElectrumSyncConfig {
539568
pub background_sync_config: Option<BackgroundSyncConfig>,
540569
/// Sync timeouts configuration.
541570
pub timeouts_config: SyncTimeoutsConfig,
571+
/// The stop gap used for BDK full scans of the on-chain wallet.
572+
///
573+
/// A full scan for each keychain stops after this many consecutive script pubkeys
574+
/// with no associated transactions. This value is only used for BDK `full_scan`
575+
/// calls, which ldk-node performs on the first on-chain wallet sync or when
576+
/// [`Self::force_wallet_full_scan`] is set. Incremental BDK `sync` calls do not use it.
577+
///
578+
/// **Default:** 20 ([`DEFAULT_FULL_SCAN_STOP_GAP`])
579+
///
580+
/// **Allowed values:** 1 ([`MIN_FULL_SCAN_STOP_GAP`]) to 1000
581+
/// ([`MAX_FULL_SCAN_STOP_GAP`]), inclusive. Values outside this range will be clamped to the
582+
/// nearest bound and a warning will be logged when the full scan runs.
583+
///
584+
/// **Note:** Large values can cause many Electrum requests, hit server rate limits,
585+
/// take a long time to complete, or cause syncs to fail with
586+
/// [`SyncTimeoutsConfig::onchain_wallet_sync_timeout_secs`].
587+
pub full_scan_stop_gap: u32,
542588
/// Whether to force BDK full scans until one succeeds.
543589
///
544590
/// This can be useful when restoring a wallet from seed on a node that has already synced
@@ -551,11 +597,16 @@ impl Default for ElectrumSyncConfig {
551597
Self {
552598
background_sync_config: Some(BackgroundSyncConfig::default()),
553599
timeouts_config: SyncTimeoutsConfig::default(),
600+
full_scan_stop_gap: DEFAULT_FULL_SCAN_STOP_GAP,
554601
force_wallet_full_scan: false,
555602
}
556603
}
557604
}
558605

606+
pub(crate) fn clamp_full_scan_stop_gap(full_scan_stop_gap: u32) -> u32 {
607+
full_scan_stop_gap.clamp(MIN_FULL_SCAN_STOP_GAP, MAX_FULL_SCAN_STOP_GAP)
608+
}
609+
559610
/// Configuration for syncing with Bitcoin Core backend via REST.
560611
#[derive(Debug, Clone)]
561612
pub struct BitcoindRestClientConfig {
@@ -711,7 +762,11 @@ pub enum AsyncPaymentsRole {
711762
mod tests {
712763
use std::str::FromStr;
713764

714-
use super::{may_announce_channel, AnnounceError, Config, NodeAlias, SocketAddress};
765+
use super::{
766+
clamp_full_scan_stop_gap, may_announce_channel, AnnounceError, Config, ElectrumSyncConfig,
767+
EsploraSyncConfig, NodeAlias, SocketAddress, DEFAULT_FULL_SCAN_STOP_GAP,
768+
MAX_FULL_SCAN_STOP_GAP, MIN_FULL_SCAN_STOP_GAP,
769+
};
715770

716771
#[test]
717772
fn node_announce_channel() {
@@ -758,4 +813,22 @@ mod tests {
758813
}
759814
assert!(may_announce_channel(&node_config).is_ok());
760815
}
816+
817+
#[test]
818+
fn full_scan_stop_gap_defaults() {
819+
assert_eq!(EsploraSyncConfig::default().full_scan_stop_gap, DEFAULT_FULL_SCAN_STOP_GAP);
820+
assert_eq!(ElectrumSyncConfig::default().full_scan_stop_gap, DEFAULT_FULL_SCAN_STOP_GAP);
821+
}
822+
823+
#[test]
824+
fn full_scan_stop_gap_is_clamped_to_valid_range() {
825+
assert_eq!(clamp_full_scan_stop_gap(MIN_FULL_SCAN_STOP_GAP), MIN_FULL_SCAN_STOP_GAP);
826+
assert_eq!(
827+
clamp_full_scan_stop_gap(DEFAULT_FULL_SCAN_STOP_GAP),
828+
DEFAULT_FULL_SCAN_STOP_GAP
829+
);
830+
assert_eq!(clamp_full_scan_stop_gap(MAX_FULL_SCAN_STOP_GAP), MAX_FULL_SCAN_STOP_GAP);
831+
assert_eq!(clamp_full_scan_stop_gap(0), MIN_FULL_SCAN_STOP_GAP);
832+
assert_eq!(clamp_full_scan_stop_gap(MAX_FULL_SCAN_STOP_GAP + 1), MAX_FULL_SCAN_STOP_GAP);
833+
}
761834
}

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: 9 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,13 +544,19 @@ 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) => {
547553
let electrum_url = format!("tcp://{}", electrsd.electrum_url);
548554
let mut sync_config = ElectrumSyncConfig::default();
549555
sync_config.background_sync_config = None;
550556
sync_config.force_wallet_full_scan = config.force_wallet_full_scan;
557+
if let Some(full_scan_stop_gap) = config.full_scan_stop_gap {
558+
sync_config.full_scan_stop_gap = full_scan_stop_gap;
559+
}
551560
builder.set_chain_source_electrum(electrum_url.clone(), Some(sync_config));
552561
},
553562
TestChainSource::BitcoindRpcSync(bitcoind) => {

tests/integration_tests_rust.rs

Lines changed: 71 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,76 @@ 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_and_electrum_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+
do_onchain_wallet_full_scan_stop_gap_recovers_far_funds(
1014+
TestChainSource::Electrum(&electrsd),
1015+
&bitcoind,
1016+
&electrsd,
1017+
)
1018+
.await;
1019+
}
1020+
1021+
async fn do_onchain_wallet_full_scan_stop_gap_recovers_far_funds(
1022+
chain_source: TestChainSource<'_>, bitcoind: &BitcoinD, electrsd: &ElectrsD,
1023+
) {
1024+
let configured_stop_gap = DEFAULT_FULL_SCAN_STOP_GAP + 5;
1025+
1026+
let address_source_config = random_config(true);
1027+
let node_entropy = address_source_config.node_entropy;
1028+
let address_source_node = setup_node(&chain_source, address_source_config);
1029+
let mut far_address = None;
1030+
for _ in 0..configured_stop_gap {
1031+
far_address = Some(address_source_node.onchain_payment().new_address().unwrap());
1032+
}
1033+
address_source_node.stop().unwrap();
1034+
drop(address_source_node);
1035+
let far_address = far_address.unwrap();
1036+
1037+
let premine_amount_sat = 100_000;
1038+
let txid = bitcoind
1039+
.client
1040+
.send_to_address(&far_address, Amount::from_sat(premine_amount_sat))
1041+
.unwrap()
1042+
.0
1043+
.parse()
1044+
.unwrap();
1045+
wait_for_tx(&electrsd.client, txid).await;
1046+
generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await;
1047+
1048+
let mut default_gap_config = random_config(true);
1049+
default_gap_config.node_entropy = node_entropy.clone();
1050+
let default_gap_node = setup_node(&chain_source, default_gap_config);
1051+
default_gap_node.sync_wallets().unwrap();
1052+
assert_eq!(
1053+
default_gap_node.list_balances().spendable_onchain_balance_sats,
1054+
0,
1055+
"default full-scan stop gap should not recover funds past its address gap"
1056+
);
1057+
default_gap_node.stop().unwrap();
1058+
drop(default_gap_node);
1059+
1060+
let mut configured_gap_config = random_config(true);
1061+
configured_gap_config.node_entropy = node_entropy;
1062+
configured_gap_config.full_scan_stop_gap = Some(configured_stop_gap);
1063+
let configured_gap_node = setup_node(&chain_source, configured_gap_config);
1064+
configured_gap_node.sync_wallets().unwrap();
1065+
assert_eq!(
1066+
configured_gap_node.list_balances().spendable_onchain_balance_sats,
1067+
premine_amount_sat,
1068+
"configured full-scan stop gap should recover funds past the default address gap"
1069+
);
1070+
}
1071+
10021072
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
10031073
async fn onchain_wallet_recovery_rescans_from_birthday_height() {
10041074
// End-to-end test for `wallet_rescan_from_height` against a bitcoind chain source. The

0 commit comments

Comments
 (0)