Skip to content

Commit 608108a

Browse files
committed
Generalize recovery_mode to an Option<RecoveryMode> struct
Rather than a binary "rescan from genesis" toggle, the new `RecoveryMode { rescan_from_height: Option<u32> }` struct lets users specify an explicit block height to rescan from on bitcoind-backed nodes. This supports importing wallets on pruned nodes where the full history is unavailable but the wallet's birthday height is known (lightningdevkit#818). For Esplora/Electrum backends, `rescan_from_height` is ignored because those clients do not expose a block-hash-by-height lookup. Instead, any `Some(RecoveryMode { .. })` forces a one-shot BDK `full_scan` on the next wallet sync, so funds sent to previously-unknown addresses are re-discovered. `None` retains the default "checkpoint at current tip, incremental sync" behavior. The struct leaves room for future recovery options (e.g. a timestamp) without another breaking change. Co-Authored-By: HAL 9000
1 parent de80aaa commit 608108a

9 files changed

Lines changed: 245 additions & 46 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# Unreleased
22

3+
## Feature and API updates
4+
- The `Builder::set_wallet_recovery_mode` method has been generalized to accept an `Option<RecoveryMode>` argument instead of being a no-argument toggle. `RecoveryMode::rescan_from_height` lets users specify an explicit block height to rescan from on bitcoind-backed nodes — useful for restoring a wallet on a pruned node where the wallet's birthday height is known but the full history is unavailable (#818). On Esplora/Electrum backends, any `Some(RecoveryMode { .. })` now forces a one-shot BDK `full_scan` on the next wallet sync to re-discover funds sent to previously-unknown addresses. This is a breaking API change.
5+
36
## Bug Fixes and Improvements
47
- Building a fresh node against a Bitcoin Core RPC or REST chain source that fails to return the current chain tip now aborts with a new `BuildError::ChainTipFetchFailed` variant instead of silently pinning the wallet birthday to genesis, which would have forced a full-history rescan once the chain source became reachable again.
58

bindings/ldk_node.udl

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ interface Builder {
6060
void set_node_alias(string node_alias);
6161
[Throws=BuildError]
6262
void set_async_payments_role(AsyncPaymentsRole? role);
63-
void set_wallet_recovery_mode();
63+
void set_wallet_recovery_mode(RecoveryMode? recovery_mode);
6464
[Throws=BuildError]
6565
Node build(NodeEntropy node_entropy);
6666
[Throws=BuildError]
@@ -241,6 +241,8 @@ dictionary BestBlock {
241241

242242
typedef enum BuildError;
243243

244+
typedef dictionary RecoveryMode;
245+
244246
[Trait, WithForeign]
245247
interface VssHeaderProvider {
246248
[Async, Throws=VssHeaderProviderError]

src/builder.rs

Lines changed: 110 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,34 @@ impl fmt::Display for BuildError {
257257

258258
impl std::error::Error for BuildError {}
259259

260+
/// Describes how the wallet should be recovered on the next startup.
261+
///
262+
/// Pass `Some(RecoveryMode { .. })` to [`NodeBuilder::set_wallet_recovery_mode`] to opt into
263+
/// recovery behavior; see [`RecoveryMode::rescan_from_height`] for the details of what each
264+
/// setting does on each chain source.
265+
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
266+
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
267+
pub struct RecoveryMode {
268+
/// Where the wallet should start rescanning the chain from on the next startup.
269+
///
270+
/// Behavior depends on the configured chain source:
271+
///
272+
/// **`Bitcoind` (RPC or REST):**
273+
/// * `None` — no wallet checkpoint is inserted; BDK rescans from genesis. This matches the
274+
/// previous `recovery_mode = true` flag.
275+
/// * `Some(h)` — the block hash at height `h` is resolved via the chain source and inserted
276+
/// as the initial wallet checkpoint, so BDK rescans forward from `h`. Useful for
277+
/// restoring a wallet on a pruned node where the full history is unavailable but the
278+
/// wallet's birthday height is known.
279+
///
280+
/// **`Esplora` / `Electrum`:** this field is ignored — the BDK client APIs for these
281+
/// backends do not currently expose a block-hash-by-height lookup. Instead, setting any
282+
/// `Some(RecoveryMode { .. })` forces a one-shot BDK `full_scan` on the next wallet sync
283+
/// after startup — even if the node has synced before — so funds sent to addresses the
284+
/// current instance does not yet know about are re-discovered.
285+
pub rescan_from_height: Option<u32>,
286+
}
287+
260288
/// A builder for an [`Node`] instance, allowing to set some configuration and module choices from
261289
/// the getgo.
262290
///
@@ -305,7 +333,7 @@ pub struct NodeBuilder {
305333
async_payments_role: Option<AsyncPaymentsRole>,
306334
runtime_handle: Option<tokio::runtime::Handle>,
307335
pathfinding_scores_sync_config: Option<PathfindingScoresSyncConfig>,
308-
recovery_mode: bool,
336+
recovery_mode: Option<RecoveryMode>,
309337
}
310338

311339
impl NodeBuilder {
@@ -323,7 +351,7 @@ impl NodeBuilder {
323351
let log_writer_config = None;
324352
let runtime_handle = None;
325353
let pathfinding_scores_sync_config = None;
326-
let recovery_mode = false;
354+
let recovery_mode = None;
327355
Self {
328356
config,
329357
chain_data_source_config,
@@ -629,13 +657,17 @@ impl NodeBuilder {
629657
Ok(self)
630658
}
631659

632-
/// Configures the [`Node`] to resync chain data from genesis on first startup, recovering any
633-
/// historical wallet funds.
660+
/// Configures the [`Node`] to perform wallet recovery on the next startup, optionally
661+
/// specifying the block height to rescan the chain from.
662+
///
663+
/// Pass `Some(RecoveryMode { .. })` to enable recovery; pass `None` to clear any previously
664+
/// configured recovery mode and use the default "checkpoint at current tip" behavior. See
665+
/// [`RecoveryMode`] for the details of what each setting does on each chain source.
634666
///
635667
/// This should only be set on first startup when importing an older wallet from a previously
636668
/// used [`NodeEntropy`].
637-
pub fn set_wallet_recovery_mode(&mut self) -> &mut Self {
638-
self.recovery_mode = true;
669+
pub fn set_wallet_recovery_mode(&mut self, mode: Option<RecoveryMode>) -> &mut Self {
670+
self.recovery_mode = mode;
639671
self
640672
}
641673

@@ -1101,13 +1133,17 @@ impl ArcedNodeBuilder {
11011133
self.inner.write().expect("lock").set_async_payments_role(role).map(|_| ())
11021134
}
11031135

1104-
/// Configures the [`Node`] to resync chain data from genesis on first startup, recovering any
1105-
/// historical wallet funds.
1136+
/// Configures the [`Node`] to perform wallet recovery on the next startup, optionally
1137+
/// specifying the block height to rescan the chain from.
1138+
///
1139+
/// Pass `Some(RecoveryMode { .. })` to enable recovery; pass `None` to clear any previously
1140+
/// configured recovery mode and use the default "checkpoint at current tip" behavior. See
1141+
/// [`RecoveryMode`] for the details of what each setting does on each chain source.
11061142
///
11071143
/// This should only be set on first startup when importing an older wallet from a previously
11081144
/// used [`NodeEntropy`].
1109-
pub fn set_wallet_recovery_mode(&self) {
1110-
self.inner.write().expect("lock").set_wallet_recovery_mode();
1145+
pub fn set_wallet_recovery_mode(&self, mode: Option<RecoveryMode>) {
1146+
self.inner.write().expect("lock").set_wallet_recovery_mode(mode);
11111147
}
11121148

11131149
/// Builds a [`Node`] instance with a [`SqliteStore`] backend and according to the options
@@ -1253,8 +1289,8 @@ fn build_with_store_internal(
12531289
gossip_source_config: Option<&GossipSourceConfig>,
12541290
liquidity_source_config: Option<&LiquiditySourceConfig>,
12551291
pathfinding_scores_sync_config: Option<&PathfindingScoresSyncConfig>,
1256-
async_payments_role: Option<AsyncPaymentsRole>, recovery_mode: bool, seed_bytes: [u8; 64],
1257-
runtime: Arc<Runtime>, logger: Arc<Logger>, kv_store: Arc<DynStore>,
1292+
async_payments_role: Option<AsyncPaymentsRole>, recovery_mode: Option<RecoveryMode>,
1293+
seed_bytes: [u8; 64], runtime: Arc<Runtime>, logger: Arc<Logger>, kv_store: Arc<DynStore>,
12581294
) -> Result<Node, BuildError> {
12591295
optionally_install_rustls_cryptoprovider();
12601296

@@ -1462,7 +1498,7 @@ fn build_with_store_internal(
14621498
// retain their existing behavior.
14631499
let is_bitcoind_source =
14641500
matches!(chain_data_source_config, Some(ChainDataSourceConfig::Bitcoind { .. }));
1465-
if !recovery_mode && chain_tip_opt.is_none() && is_bitcoind_source {
1501+
if recovery_mode.is_none() && chain_tip_opt.is_none() && is_bitcoind_source {
14661502
log_error!(
14671503
logger,
14681504
"Failed to determine chain tip on first startup. Aborting to avoid pinning the wallet birthday to genesis."
@@ -1478,23 +1514,62 @@ fn build_with_store_internal(
14781514
BuildError::WalletSetupFailed
14791515
})?;
14801516

1481-
if !recovery_mode {
1482-
if let Some(best_block) = chain_tip_opt {
1483-
// Insert the first checkpoint if we have it, to avoid resyncing from genesis.
1484-
// TODO: Use a proper wallet birthday once BDK supports it.
1485-
let mut latest_checkpoint = wallet.latest_checkpoint();
1486-
let block_id = bdk_chain::BlockId {
1487-
height: best_block.height,
1488-
hash: best_block.block_hash,
1489-
};
1490-
latest_checkpoint = latest_checkpoint.insert(block_id);
1491-
let update =
1492-
bdk_wallet::Update { chain: Some(latest_checkpoint), ..Default::default() };
1493-
wallet.apply_update(update).map_err(|e| {
1494-
log_error!(logger, "Failed to apply checkpoint during wallet setup: {}", e);
1517+
// Decide which block (if any) to insert as the initial BDK checkpoint.
1518+
// - No recovery mode: use the current chain tip to avoid any rescan.
1519+
// - Recovery mode with an explicit `rescan_from_height` on a bitcoind backend:
1520+
// resolve the block hash at that height and use it as the checkpoint, so BDK
1521+
// rescans forward from there.
1522+
// - Recovery mode otherwise (no explicit height, or non-bitcoind backend): skip
1523+
// the checkpoint entirely. For bitcoind this falls back to a full rescan from
1524+
// genesis; for Esplora/Electrum the on-chain wallet syncer forces a one-shot
1525+
// `full_scan` (see `Wallet::take_force_full_scan`).
1526+
let checkpoint_block = match recovery_mode {
1527+
None => chain_tip_opt,
1528+
Some(RecoveryMode { rescan_from_height: Some(height) }) if is_bitcoind_source => {
1529+
let utxo_source = chain_source.as_utxo_source().ok_or_else(|| {
1530+
log_error!(
1531+
logger,
1532+
"Recovery mode requested a rescan height but the chain source does not support block-by-height lookups.",
1533+
);
14951534
BuildError::WalletSetupFailed
14961535
})?;
1497-
}
1536+
let hash_res = runtime.block_on(async {
1537+
lightning_block_sync::gossip::UtxoSource::get_block_hash_by_height(
1538+
&utxo_source,
1539+
height,
1540+
)
1541+
.await
1542+
});
1543+
match hash_res {
1544+
Ok(hash) => Some(BestBlock { block_hash: hash, height }),
1545+
Err(e) => {
1546+
log_error!(
1547+
logger,
1548+
"Failed to resolve block hash at height {} for wallet rescan: {:?}",
1549+
height,
1550+
e,
1551+
);
1552+
return Err(BuildError::WalletSetupFailed);
1553+
},
1554+
}
1555+
},
1556+
Some(_) => None,
1557+
};
1558+
1559+
if let Some(best_block) = checkpoint_block {
1560+
// Insert the checkpoint so BDK starts scanning from there instead of from
1561+
// genesis.
1562+
// TODO: Use a proper wallet birthday once BDK supports it.
1563+
let mut latest_checkpoint = wallet.latest_checkpoint();
1564+
let block_id =
1565+
bdk_chain::BlockId { height: best_block.height, hash: best_block.block_hash };
1566+
latest_checkpoint = latest_checkpoint.insert(block_id);
1567+
let update =
1568+
bdk_wallet::Update { chain: Some(latest_checkpoint), ..Default::default() };
1569+
wallet.apply_update(update).map_err(|e| {
1570+
log_error!(logger, "Failed to apply checkpoint during wallet setup: {}", e);
1571+
BuildError::WalletSetupFailed
1572+
})?;
14981573
}
14991574
wallet
15001575
},
@@ -1514,6 +1589,12 @@ fn build_with_store_internal(
15141589
},
15151590
};
15161591

1592+
// On Esplora/Electrum the initial wallet-checkpoint logic above cannot honor a specific
1593+
// rescan height because the backends don't expose a block-hash-by-height lookup. When the
1594+
// user has explicitly opted into recovery mode we instead force the next on-chain sync to
1595+
// escalate to a BDK `full_scan` so funds sent to previously-unknown addresses are
1596+
// re-discovered.
1597+
let force_full_scan = recovery_mode.is_some();
15171598
let wallet = Arc::new(Wallet::new(
15181599
bdk_wallet,
15191600
wallet_persister,
@@ -1524,6 +1605,7 @@ fn build_with_store_internal(
15241605
Arc::clone(&config),
15251606
Arc::clone(&logger),
15261607
Arc::clone(&pending_payment_store),
1608+
force_full_scan,
15271609
));
15281610

15291611
// Initialize the KeysManager

src/chain/electrum.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,9 +125,11 @@ impl ElectrumChainSource {
125125
return Err(Error::FeerateEstimationUpdateFailed);
126126
};
127127
// 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 =
128+
// Otherwise just do an incremental sync. Also take one-shot priority over an incremental
129+
// sync if the user opted into recovery mode via `NodeBuilder::set_wallet_recovery_mode`.
130+
let has_prior_sync =
130131
self.node_metrics.read().expect("lock").latest_onchain_wallet_sync_timestamp.is_some();
132+
let incremental_sync = has_prior_sync && !onchain_wallet.take_force_full_scan();
131133

132134
let apply_wallet_update =
133135
|update_res: Result<BdkUpdate, Error>, now: Instant| match update_res {

src/chain/esplora.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,9 +101,11 @@ impl EsploraChainSource {
101101

102102
async fn sync_onchain_wallet_inner(&self, onchain_wallet: Arc<Wallet>) -> Result<(), Error> {
103103
// 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 =
104+
// Otherwise just do an incremental sync. Also take one-shot priority over an incremental
105+
// sync if the user opted into recovery mode via `NodeBuilder::set_wallet_recovery_mode`.
106+
let has_prior_sync =
106107
self.node_metrics.read().expect("lock").latest_onchain_wallet_sync_timestamp.is_some();
108+
let incremental_sync = has_prior_sync && !onchain_wallet.take_force_full_scan();
107109

108110
macro_rules! get_and_apply_wallet_update {
109111
($sync_future: expr) => {{

src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,9 +124,9 @@ use bitcoin::FeeRate;
124124
use bitcoin::{Address, Amount};
125125
#[cfg(feature = "uniffi")]
126126
pub use builder::ArcedNodeBuilder as Builder;
127-
pub use builder::BuildError;
128127
#[cfg(not(feature = "uniffi"))]
129128
pub use builder::NodeBuilder as Builder;
129+
pub use builder::{BuildError, RecoveryMode};
130130
use chain::ChainSource;
131131
use config::{
132132
default_user_config, may_announce_channel, AsyncPaymentsRole, ChannelConfig, Config,

src/wallet/mod.rs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use std::future::Future;
99
use std::ops::Deref;
1010
use std::str::FromStr;
11+
use std::sync::atomic::{AtomicBool, Ordering};
1112
use std::sync::{Arc, Mutex};
1213

1314
use bdk_chain::spk_client::{FullScanRequest, SyncRequest};
@@ -89,6 +90,11 @@ pub(crate) struct Wallet {
8990
config: Arc<Config>,
9091
logger: Arc<Logger>,
9192
pending_payment_store: Arc<PendingPaymentStore>,
93+
// If set, the next on-chain wallet sync will force a BDK `full_scan` even when the node has
94+
// synced before. Used on Esplora/Electrum backends to honor a user-requested recovery mode:
95+
// those backends don't expose a block-hash-by-height lookup, so the only way to rediscover
96+
// funds sent to previously-unknown addresses is to re-run the gap-limit scan once.
97+
force_full_scan: AtomicBool,
9298
}
9399

94100
impl Wallet {
@@ -97,7 +103,7 @@ impl Wallet {
97103
wallet_persister: KVStoreWalletPersister, broadcaster: Arc<Broadcaster>,
98104
fee_estimator: Arc<OnchainFeeEstimator>, chain_source: Arc<ChainSource>,
99105
payment_store: Arc<PaymentStore>, config: Arc<Config>, logger: Arc<Logger>,
100-
pending_payment_store: Arc<PendingPaymentStore>,
106+
pending_payment_store: Arc<PendingPaymentStore>, force_full_scan: bool,
101107
) -> Self {
102108
let inner = Mutex::new(wallet);
103109
let persister = Mutex::new(wallet_persister);
@@ -111,9 +117,19 @@ impl Wallet {
111117
config,
112118
logger,
113119
pending_payment_store,
120+
force_full_scan: AtomicBool::new(force_full_scan),
114121
}
115122
}
116123

124+
/// Consume a pending "force a full_scan on the next sync" flag.
125+
///
126+
/// Returns `true` exactly once if the flag was set at construction time, and `false` on every
127+
/// subsequent call. Used by the Esplora/Electrum syncers to decide whether to escalate an
128+
/// incremental sync to a full_scan.
129+
pub(crate) fn take_force_full_scan(&self) -> bool {
130+
self.force_full_scan.swap(false, Ordering::AcqRel)
131+
}
132+
117133
pub(crate) fn get_full_scan_request(&self) -> FullScanRequest<KeychainKind> {
118134
self.inner.lock().expect("lock").start_full_scan().build()
119135
}

tests/common/mod.rs

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ use ldk_node::io::sqlite_store::SqliteStore;
3737
use ldk_node::payment::{PaymentDirection, PaymentKind, PaymentStatus};
3838
use ldk_node::{
3939
Builder, ChannelShutdownState, CustomTlvRecord, Event, LightningBalance, Node, NodeError,
40-
PendingSweepBalance, UserChannelId,
40+
PendingSweepBalance, RecoveryMode, UserChannelId,
4141
};
4242
use lightning::io;
4343
use lightning::ln::msgs::SocketAddress;
@@ -349,7 +349,7 @@ pub(crate) struct TestConfig {
349349
pub store_type: TestStoreType,
350350
pub node_entropy: NodeEntropy,
351351
pub async_payments_role: Option<AsyncPaymentsRole>,
352-
pub recovery_mode: bool,
352+
pub recovery_mode: Option<RecoveryMode>,
353353
}
354354

355355
impl Default for TestConfig {
@@ -361,7 +361,7 @@ impl Default for TestConfig {
361361
let mnemonic = generate_entropy_mnemonic(None);
362362
let node_entropy = NodeEntropy::from_bip39_mnemonic(mnemonic, None);
363363
let async_payments_role = None;
364-
let recovery_mode = false;
364+
let recovery_mode = None;
365365
TestConfig {
366366
node_config,
367367
log_writer,
@@ -497,8 +497,8 @@ pub(crate) fn setup_node(chain_source: &TestChainSource, config: TestConfig) ->
497497

498498
builder.set_async_payments_role(config.async_payments_role).unwrap();
499499

500-
if config.recovery_mode {
501-
builder.set_wallet_recovery_mode();
500+
if config.recovery_mode.is_some() {
501+
builder.set_wallet_recovery_mode(config.recovery_mode);
502502
}
503503

504504
let node = match config.store_type {
@@ -509,10 +509,6 @@ pub(crate) fn setup_node(chain_source: &TestChainSource, config: TestConfig) ->
509509
TestStoreType::Sqlite => builder.build(config.node_entropy.into()).unwrap(),
510510
};
511511

512-
if config.recovery_mode {
513-
builder.set_wallet_recovery_mode();
514-
}
515-
516512
node.start().unwrap();
517513
assert!(node.status().is_running);
518514
assert!(node.status().latest_fee_rate_cache_update_timestamp.is_some());

0 commit comments

Comments
 (0)