From de80aaa695b78f27b488f2027e6adce42748b4e7 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 21 Apr 2026 14:37:34 +0200 Subject: [PATCH 1/2] Abort on first-startup chain tip fetch failure When a fresh node's bitcoind RPC/REST chain source fails to return the current chain tip, we previously silently fell back to the genesis block as the wallet birthday. The next successful startup would then force a full-history rescan of the whole chain. Instead, return a new BuildError::ChainTipFetchFailed on the first build so the misconfiguration surfaces immediately and no stale fresh state is persisted. Restarts with a previously-persisted wallet are unaffected: a transient chain source outage on an existing node still allows startup to proceed. Esplora/Electrum backends currently never expose a tip at build time so the guard only fires for bitcoind sources; the latent wallet-birthday-at-genesis issue on those backends is left for a follow-up. Co-Authored-By: HAL 9000 --- CHANGELOG.md | 5 +++++ src/builder.rs | 30 ++++++++++++++++++++++++++++++ tests/integration_tests_rust.rs | 32 +++++++++++++++++++++++++++++++- 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38b7d6de5..41bc9bfb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# Unreleased + +## Bug Fixes and Improvements +- 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. + # 0.7.0 - Dec. 3, 2025 This seventh minor release introduces numerous new features, bug fixes, and API improvements. In particular, it adds support for channel Splicing, Async Payments, as well as sourcing chain data from a Bitcoin Core REST backend. diff --git a/src/builder.rs b/src/builder.rs index a55b49d7e..90033cb78 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -201,6 +201,13 @@ pub enum BuildError { AsyncPaymentsConfigMismatch, /// An attempt to setup a DNS Resolver failed. DNSResolverSetupFailed, + /// We failed to determine the current chain tip on first startup. + /// + /// Returned when a fresh node is built against a Bitcoin Core RPC or REST chain source that + /// is unreachable or misconfigured, so we cannot learn the tip height/hash to use as the + /// wallet birthday. Falling back to genesis would silently force a full-history rescan on + /// the next successful startup, so we abort instead. + ChainTipFetchFailed, } impl fmt::Display for BuildError { @@ -238,6 +245,12 @@ impl fmt::Display for BuildError { Self::DNSResolverSetupFailed => { write!(f, "An attempt to setup a DNS resolver has failed.") }, + Self::ChainTipFetchFailed => { + write!( + f, + "Failed to determine the current chain tip on first startup. Verify the chain data source is reachable and correctly configured." + ) + }, } } } @@ -1440,6 +1453,23 @@ fn build_with_store_internal( let bdk_wallet = match wallet_opt { Some(wallet) => wallet, None => { + // Guard against silently setting the wallet birthday to genesis on a fresh node: + // if we are creating a new wallet but failed to learn the current chain tip from + // a Bitcoin Core RPC/REST backend, we'd otherwise persist fresh wallet state + // pinned at height 0 and force a full-history rescan once the backend comes back. + // Abort cleanly instead so the misconfiguration surfaces on the first startup. + // Esplora/Electrum backends currently never return a tip at build time, so they + // retain their existing behavior. + let is_bitcoind_source = + matches!(chain_data_source_config, Some(ChainDataSourceConfig::Bitcoind { .. })); + if !recovery_mode && chain_tip_opt.is_none() && is_bitcoind_source { + log_error!( + logger, + "Failed to determine chain tip on first startup. Aborting to avoid pinning the wallet birthday to genesis." + ); + return Err(BuildError::ChainTipFetchFailed); + } + let mut wallet = BdkWallet::create(descriptor, change_descriptor) .network(config.network) .create_wallet(&mut wallet_persister) diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index d2c057a16..105933cb5 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -35,7 +35,7 @@ use ldk_node::payment::{ ConfirmationStatus, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, UnifiedPaymentResult, }; -use ldk_node::{Builder, Event, NodeError}; +use ldk_node::{BuildError, Builder, Event, NodeError}; use lightning::ln::channelmanager::PaymentId; use lightning::routing::gossip::{NodeAlias, NodeId}; use lightning::routing::router::RouteParametersConfig; @@ -743,6 +743,36 @@ async fn onchain_wallet_recovery() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn build_aborts_on_first_startup_bitcoind_tip_fetch_failure() { + // A fresh node pointed at an unreachable bitcoind RPC endpoint must not silently + // fall back to genesis as the wallet birthday. The build must abort cleanly so the + // misconfiguration surfaces immediately. + let config = random_config(false); + let entropy = config.node_entropy; + + setup_builder!(builder, config.node_config); + // Pick a localhost port that is extremely unlikely to be bound. The kernel will + // refuse the connection immediately so the test does not have to wait for the + // chain-polling timeout. + let unreachable_port: u16 = 1; + builder.set_chain_source_bitcoind_rpc( + "127.0.0.1".to_string(), + unreachable_port, + "user".to_string(), + "password".to_string(), + ); + + let res = builder.build(entropy.into()); + match res { + Err(BuildError::ChainTipFetchFailed) => {}, + other => panic!( + "expected BuildError::ChainTipFetchFailed on fresh node with unreachable bitcoind, got {:?}", + other.map(|_| "Ok(_)") + ), + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_rbf_via_mempool() { run_rbf_test(false).await; From 608108add857ff403a4df1a403b8ffcef65ea733 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 21 Apr 2026 14:52:25 +0200 Subject: [PATCH 2/2] Generalize `recovery_mode` to an `Option` struct Rather than a binary "rescan from genesis" toggle, the new `RecoveryMode { rescan_from_height: Option }` 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/ldk-node#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 --- CHANGELOG.md | 3 + bindings/ldk_node.udl | 4 +- src/builder.rs | 138 +++++++++++++++++++++++++------- src/chain/electrum.rs | 6 +- src/chain/esplora.rs | 6 +- src/lib.rs | 2 +- src/wallet/mod.rs | 18 ++++- tests/common/mod.rs | 14 ++-- tests/integration_tests_rust.rs | 100 ++++++++++++++++++++++- 9 files changed, 245 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41bc9bfb4..5e65418d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Unreleased +## Feature and API updates +- The `Builder::set_wallet_recovery_mode` method has been generalized to accept an `Option` 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. + ## Bug Fixes and Improvements - 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. diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index c32604708..ee87b3ac8 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -60,7 +60,7 @@ interface Builder { void set_node_alias(string node_alias); [Throws=BuildError] void set_async_payments_role(AsyncPaymentsRole? role); - void set_wallet_recovery_mode(); + void set_wallet_recovery_mode(RecoveryMode? recovery_mode); [Throws=BuildError] Node build(NodeEntropy node_entropy); [Throws=BuildError] @@ -241,6 +241,8 @@ dictionary BestBlock { typedef enum BuildError; +typedef dictionary RecoveryMode; + [Trait, WithForeign] interface VssHeaderProvider { [Async, Throws=VssHeaderProviderError] diff --git a/src/builder.rs b/src/builder.rs index 90033cb78..e025ebcbd 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -257,6 +257,34 @@ impl fmt::Display for BuildError { impl std::error::Error for BuildError {} +/// Describes how the wallet should be recovered on the next startup. +/// +/// Pass `Some(RecoveryMode { .. })` to [`NodeBuilder::set_wallet_recovery_mode`] to opt into +/// recovery behavior; see [`RecoveryMode::rescan_from_height`] for the details of what each +/// setting does on each chain source. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct RecoveryMode { + /// Where the wallet should start rescanning the chain from on the next startup. + /// + /// Behavior depends on the configured chain source: + /// + /// **`Bitcoind` (RPC or REST):** + /// * `None` — no wallet checkpoint is inserted; BDK rescans from genesis. This matches the + /// previous `recovery_mode = true` flag. + /// * `Some(h)` — the block hash at height `h` is resolved via the chain source and inserted + /// as the initial wallet checkpoint, so BDK rescans forward from `h`. Useful for + /// restoring a wallet on a pruned node where the full history is unavailable but the + /// wallet's birthday height is known. + /// + /// **`Esplora` / `Electrum`:** this field is ignored — the BDK client APIs for these + /// backends do not currently expose a block-hash-by-height lookup. Instead, setting any + /// `Some(RecoveryMode { .. })` forces a one-shot BDK `full_scan` on the next wallet sync + /// after startup — even if the node has synced before — so funds sent to addresses the + /// current instance does not yet know about are re-discovered. + pub rescan_from_height: Option, +} + /// A builder for an [`Node`] instance, allowing to set some configuration and module choices from /// the getgo. /// @@ -305,7 +333,7 @@ pub struct NodeBuilder { async_payments_role: Option, runtime_handle: Option, pathfinding_scores_sync_config: Option, - recovery_mode: bool, + recovery_mode: Option, } impl NodeBuilder { @@ -323,7 +351,7 @@ impl NodeBuilder { let log_writer_config = None; let runtime_handle = None; let pathfinding_scores_sync_config = None; - let recovery_mode = false; + let recovery_mode = None; Self { config, chain_data_source_config, @@ -629,13 +657,17 @@ impl NodeBuilder { Ok(self) } - /// Configures the [`Node`] to resync chain data from genesis on first startup, recovering any - /// historical wallet funds. + /// Configures the [`Node`] to perform wallet recovery on the next startup, optionally + /// specifying the block height to rescan the chain from. + /// + /// Pass `Some(RecoveryMode { .. })` to enable recovery; pass `None` to clear any previously + /// configured recovery mode and use the default "checkpoint at current tip" behavior. See + /// [`RecoveryMode`] for the details of what each setting does on each chain source. /// /// This should only be set on first startup when importing an older wallet from a previously /// used [`NodeEntropy`]. - pub fn set_wallet_recovery_mode(&mut self) -> &mut Self { - self.recovery_mode = true; + pub fn set_wallet_recovery_mode(&mut self, mode: Option) -> &mut Self { + self.recovery_mode = mode; self } @@ -1101,13 +1133,17 @@ impl ArcedNodeBuilder { self.inner.write().expect("lock").set_async_payments_role(role).map(|_| ()) } - /// Configures the [`Node`] to resync chain data from genesis on first startup, recovering any - /// historical wallet funds. + /// Configures the [`Node`] to perform wallet recovery on the next startup, optionally + /// specifying the block height to rescan the chain from. + /// + /// Pass `Some(RecoveryMode { .. })` to enable recovery; pass `None` to clear any previously + /// configured recovery mode and use the default "checkpoint at current tip" behavior. See + /// [`RecoveryMode`] for the details of what each setting does on each chain source. /// /// This should only be set on first startup when importing an older wallet from a previously /// used [`NodeEntropy`]. - pub fn set_wallet_recovery_mode(&self) { - self.inner.write().expect("lock").set_wallet_recovery_mode(); + pub fn set_wallet_recovery_mode(&self, mode: Option) { + self.inner.write().expect("lock").set_wallet_recovery_mode(mode); } /// Builds a [`Node`] instance with a [`SqliteStore`] backend and according to the options @@ -1253,8 +1289,8 @@ fn build_with_store_internal( gossip_source_config: Option<&GossipSourceConfig>, liquidity_source_config: Option<&LiquiditySourceConfig>, pathfinding_scores_sync_config: Option<&PathfindingScoresSyncConfig>, - async_payments_role: Option, recovery_mode: bool, seed_bytes: [u8; 64], - runtime: Arc, logger: Arc, kv_store: Arc, + async_payments_role: Option, recovery_mode: Option, + seed_bytes: [u8; 64], runtime: Arc, logger: Arc, kv_store: Arc, ) -> Result { optionally_install_rustls_cryptoprovider(); @@ -1462,7 +1498,7 @@ fn build_with_store_internal( // retain their existing behavior. let is_bitcoind_source = matches!(chain_data_source_config, Some(ChainDataSourceConfig::Bitcoind { .. })); - if !recovery_mode && chain_tip_opt.is_none() && is_bitcoind_source { + if recovery_mode.is_none() && chain_tip_opt.is_none() && is_bitcoind_source { log_error!( logger, "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( BuildError::WalletSetupFailed })?; - if !recovery_mode { - if let Some(best_block) = chain_tip_opt { - // Insert the first checkpoint if we have it, to avoid resyncing from genesis. - // TODO: Use a proper wallet birthday once BDK supports it. - let mut latest_checkpoint = wallet.latest_checkpoint(); - let block_id = bdk_chain::BlockId { - height: best_block.height, - hash: best_block.block_hash, - }; - latest_checkpoint = latest_checkpoint.insert(block_id); - let update = - bdk_wallet::Update { chain: Some(latest_checkpoint), ..Default::default() }; - wallet.apply_update(update).map_err(|e| { - log_error!(logger, "Failed to apply checkpoint during wallet setup: {}", e); + // Decide which block (if any) to insert as the initial BDK checkpoint. + // - No recovery mode: use the current chain tip to avoid any rescan. + // - Recovery mode with an explicit `rescan_from_height` on a bitcoind backend: + // resolve the block hash at that height and use it as the checkpoint, so BDK + // rescans forward from there. + // - Recovery mode otherwise (no explicit height, or non-bitcoind backend): skip + // the checkpoint entirely. For bitcoind this falls back to a full rescan from + // genesis; for Esplora/Electrum the on-chain wallet syncer forces a one-shot + // `full_scan` (see `Wallet::take_force_full_scan`). + let checkpoint_block = match recovery_mode { + None => chain_tip_opt, + Some(RecoveryMode { rescan_from_height: Some(height) }) if is_bitcoind_source => { + let utxo_source = chain_source.as_utxo_source().ok_or_else(|| { + log_error!( + logger, + "Recovery mode requested a rescan height but the chain source does not support block-by-height lookups.", + ); BuildError::WalletSetupFailed })?; - } + let hash_res = runtime.block_on(async { + lightning_block_sync::gossip::UtxoSource::get_block_hash_by_height( + &utxo_source, + height, + ) + .await + }); + match hash_res { + Ok(hash) => Some(BestBlock { block_hash: hash, height }), + Err(e) => { + log_error!( + logger, + "Failed to resolve block hash at height {} for wallet rescan: {:?}", + height, + e, + ); + return Err(BuildError::WalletSetupFailed); + }, + } + }, + Some(_) => None, + }; + + if let Some(best_block) = checkpoint_block { + // Insert the checkpoint so BDK starts scanning from there instead of from + // genesis. + // TODO: Use a proper wallet birthday once BDK supports it. + let mut latest_checkpoint = wallet.latest_checkpoint(); + let block_id = + bdk_chain::BlockId { height: best_block.height, hash: best_block.block_hash }; + latest_checkpoint = latest_checkpoint.insert(block_id); + let update = + bdk_wallet::Update { chain: Some(latest_checkpoint), ..Default::default() }; + wallet.apply_update(update).map_err(|e| { + log_error!(logger, "Failed to apply checkpoint during wallet setup: {}", e); + BuildError::WalletSetupFailed + })?; } wallet }, @@ -1514,6 +1589,12 @@ fn build_with_store_internal( }, }; + // On Esplora/Electrum the initial wallet-checkpoint logic above cannot honor a specific + // rescan height because the backends don't expose a block-hash-by-height lookup. When the + // user has explicitly opted into recovery mode we instead force the next on-chain sync to + // escalate to a BDK `full_scan` so funds sent to previously-unknown addresses are + // re-discovered. + let force_full_scan = recovery_mode.is_some(); let wallet = Arc::new(Wallet::new( bdk_wallet, wallet_persister, @@ -1524,6 +1605,7 @@ fn build_with_store_internal( Arc::clone(&config), Arc::clone(&logger), Arc::clone(&pending_payment_store), + force_full_scan, )); // Initialize the KeysManager diff --git a/src/chain/electrum.rs b/src/chain/electrum.rs index 5199c135d..d44d26897 100644 --- a/src/chain/electrum.rs +++ b/src/chain/electrum.rs @@ -125,9 +125,11 @@ impl ElectrumChainSource { return Err(Error::FeerateEstimationUpdateFailed); }; // If this is our first sync, do a full scan with the configured gap limit. - // Otherwise just do an incremental sync. - let incremental_sync = + // Otherwise just do an incremental sync. Also take one-shot priority over an incremental + // sync if the user opted into recovery mode via `NodeBuilder::set_wallet_recovery_mode`. + let has_prior_sync = self.node_metrics.read().expect("lock").latest_onchain_wallet_sync_timestamp.is_some(); + let incremental_sync = has_prior_sync && !onchain_wallet.take_force_full_scan(); let apply_wallet_update = |update_res: Result, now: Instant| match update_res { diff --git a/src/chain/esplora.rs b/src/chain/esplora.rs index d0c683c74..1aa8fe674 100644 --- a/src/chain/esplora.rs +++ b/src/chain/esplora.rs @@ -101,9 +101,11 @@ impl EsploraChainSource { async fn sync_onchain_wallet_inner(&self, onchain_wallet: Arc) -> Result<(), Error> { // If this is our first sync, do a full scan with the configured gap limit. - // Otherwise just do an incremental sync. - let incremental_sync = + // Otherwise just do an incremental sync. Also take one-shot priority over an incremental + // sync if the user opted into recovery mode via `NodeBuilder::set_wallet_recovery_mode`. + let has_prior_sync = self.node_metrics.read().expect("lock").latest_onchain_wallet_sync_timestamp.is_some(); + let incremental_sync = has_prior_sync && !onchain_wallet.take_force_full_scan(); macro_rules! get_and_apply_wallet_update { ($sync_future: expr) => {{ diff --git a/src/lib.rs b/src/lib.rs index dd82c39f9..42740dc38 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -124,9 +124,9 @@ use bitcoin::FeeRate; use bitcoin::{Address, Amount}; #[cfg(feature = "uniffi")] pub use builder::ArcedNodeBuilder as Builder; -pub use builder::BuildError; #[cfg(not(feature = "uniffi"))] pub use builder::NodeBuilder as Builder; +pub use builder::{BuildError, RecoveryMode}; use chain::ChainSource; use config::{ default_user_config, may_announce_channel, AsyncPaymentsRole, ChannelConfig, Config, diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index cb982e303..4c72a5230 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -8,6 +8,7 @@ use std::future::Future; use std::ops::Deref; use std::str::FromStr; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use bdk_chain::spk_client::{FullScanRequest, SyncRequest}; @@ -89,6 +90,11 @@ pub(crate) struct Wallet { config: Arc, logger: Arc, pending_payment_store: Arc, + // If set, the next on-chain wallet sync will force a BDK `full_scan` even when the node has + // synced before. Used on Esplora/Electrum backends to honor a user-requested recovery mode: + // those backends don't expose a block-hash-by-height lookup, so the only way to rediscover + // funds sent to previously-unknown addresses is to re-run the gap-limit scan once. + force_full_scan: AtomicBool, } impl Wallet { @@ -97,7 +103,7 @@ impl Wallet { wallet_persister: KVStoreWalletPersister, broadcaster: Arc, fee_estimator: Arc, chain_source: Arc, payment_store: Arc, config: Arc, logger: Arc, - pending_payment_store: Arc, + pending_payment_store: Arc, force_full_scan: bool, ) -> Self { let inner = Mutex::new(wallet); let persister = Mutex::new(wallet_persister); @@ -111,9 +117,19 @@ impl Wallet { config, logger, pending_payment_store, + force_full_scan: AtomicBool::new(force_full_scan), } } + /// Consume a pending "force a full_scan on the next sync" flag. + /// + /// Returns `true` exactly once if the flag was set at construction time, and `false` on every + /// subsequent call. Used by the Esplora/Electrum syncers to decide whether to escalate an + /// incremental sync to a full_scan. + pub(crate) fn take_force_full_scan(&self) -> bool { + self.force_full_scan.swap(false, Ordering::AcqRel) + } + pub(crate) fn get_full_scan_request(&self) -> FullScanRequest { self.inner.lock().expect("lock").start_full_scan().build() } diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 850c6f22c..e7e9f0998 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -37,7 +37,7 @@ use ldk_node::io::sqlite_store::SqliteStore; use ldk_node::payment::{PaymentDirection, PaymentKind, PaymentStatus}; use ldk_node::{ Builder, ChannelShutdownState, CustomTlvRecord, Event, LightningBalance, Node, NodeError, - PendingSweepBalance, UserChannelId, + PendingSweepBalance, RecoveryMode, UserChannelId, }; use lightning::io; use lightning::ln::msgs::SocketAddress; @@ -349,7 +349,7 @@ pub(crate) struct TestConfig { pub store_type: TestStoreType, pub node_entropy: NodeEntropy, pub async_payments_role: Option, - pub recovery_mode: bool, + pub recovery_mode: Option, } impl Default for TestConfig { @@ -361,7 +361,7 @@ impl Default for TestConfig { let mnemonic = generate_entropy_mnemonic(None); let node_entropy = NodeEntropy::from_bip39_mnemonic(mnemonic, None); let async_payments_role = None; - let recovery_mode = false; + let recovery_mode = None; TestConfig { node_config, log_writer, @@ -497,8 +497,8 @@ pub(crate) fn setup_node(chain_source: &TestChainSource, config: TestConfig) -> builder.set_async_payments_role(config.async_payments_role).unwrap(); - if config.recovery_mode { - builder.set_wallet_recovery_mode(); + if config.recovery_mode.is_some() { + builder.set_wallet_recovery_mode(config.recovery_mode); } let node = match config.store_type { @@ -509,10 +509,6 @@ pub(crate) fn setup_node(chain_source: &TestChainSource, config: TestConfig) -> TestStoreType::Sqlite => builder.build(config.node_entropy.into()).unwrap(), }; - if config.recovery_mode { - builder.set_wallet_recovery_mode(); - } - node.start().unwrap(); assert!(node.status().is_running); assert!(node.status().latest_fee_rate_cache_update_timestamp.is_some()); diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 105933cb5..034efc610 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -35,7 +35,7 @@ use ldk_node::payment::{ ConfirmationStatus, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, UnifiedPaymentResult, }; -use ldk_node::{BuildError, Builder, Event, NodeError}; +use ldk_node::{BuildError, Builder, Event, NodeError, RecoveryMode}; use lightning::ln::channelmanager::PaymentId; use lightning::routing::gossip::{NodeAlias, NodeId}; use lightning::routing::router::RouteParametersConfig; @@ -710,7 +710,7 @@ async fn onchain_wallet_recovery() { // Now we start from scratch, only the seed remains the same. let mut recovered_config = random_config(true); recovered_config.node_entropy = original_node_entropy; - recovered_config.recovery_mode = true; + recovered_config.recovery_mode = Some(RecoveryMode::default()); let recovered_node = setup_node(&chain_source, recovered_config); recovered_node.sync_wallets().unwrap(); @@ -743,6 +743,102 @@ async fn onchain_wallet_recovery() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn onchain_wallet_recovery_rescans_from_birthday_height() { + // End-to-end test for `RecoveryMode { rescan_from_height }` against a bitcoind chain + // source. The scenario: + // + // 1. Create a node at some "birthday" height and generate two receive addresses. + // 2. Shut the node down and drop all persisted state except the seed. + // 3. Advance the chain past the birthday. + // 4. Send funds to the addresses generated at the birthday height and confirm them. + // 5. Restart a fresh node with just the seed (no recovery mode). Its wallet birthday + // is pinned at the current tip, which is above the blocks containing the funding + // transactions — so the node must not see the funds. + // 6. Restart again with `RecoveryMode { rescan_from_height: Some(birthday) }`. Now + // the wallet must find and report both funding transactions. + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + // We specifically exercise the bitcoind RPC backend because that's where + // `rescan_from_height` is honored precisely (via `get_block_hash_by_height`). + let chain_source = TestChainSource::BitcoindRpcSync(&bitcoind); + + // Mine the initial 101 blocks so bitcoind's wallet can fund our later sends. + premine_blocks(&bitcoind.client, &electrsd.client).await; + + // Step 1: bring up an "original" node at the birthday height and generate addresses. + let original_config = random_config(true); + let original_node_entropy = original_config.node_entropy; + let original_node = setup_node(&chain_source, original_config); + + let premine_amount_sat = 100_000; + + let addr_1 = original_node.onchain_payment().new_address().unwrap(); + let addr_2 = original_node.onchain_payment().new_address().unwrap(); + + let birthday_height: u32 = bitcoind + .client + .get_blockchain_info() + .expect("failed to get blockchain info") + .blocks + .try_into() + .unwrap(); + + // Step 2: shut the node down and drop its state. + original_node.stop().unwrap(); + drop(original_node); + + // Step 3: advance the chain past the birthday, so a fresh node would otherwise pin its + // wallet birthday at a height above the funding transactions in step 4. + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 10).await; + + // Step 4: fund both addresses and confirm them. + let txid_1 = bitcoind + .client + .send_to_address(&addr_1, Amount::from_sat(premine_amount_sat)) + .unwrap() + .0 + .parse() + .unwrap(); + wait_for_tx(&electrsd.client, txid_1).await; + let txid_2 = bitcoind + .client + .send_to_address(&addr_2, Amount::from_sat(premine_amount_sat)) + .unwrap() + .0 + .parse() + .unwrap(); + wait_for_tx(&electrsd.client, txid_2).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; + + // Step 5: restart a fresh node with only the seed and no recovery mode. It must NOT see + // the funds, because its wallet birthday sits above the funding transactions. + let mut pinned_config = random_config(true); + pinned_config.node_entropy = original_node_entropy; + let pinned_node = setup_node(&chain_source, pinned_config); + pinned_node.sync_wallets().unwrap(); + assert_eq!( + pinned_node.list_balances().spendable_onchain_balance_sats, + 0, + "fresh node without recovery mode should not find funds below its wallet birthday" + ); + pinned_node.stop().unwrap(); + drop(pinned_node); + + // Step 6: restart with recovery mode rescanning from the birthday height. Funds must be + // re-discovered. + let mut recovered_config = random_config(true); + recovered_config.node_entropy = original_node_entropy; + recovered_config.recovery_mode = + Some(RecoveryMode { rescan_from_height: Some(birthday_height) }); + let recovered_node = setup_node(&chain_source, recovered_config); + recovered_node.sync_wallets().unwrap(); + assert_eq!( + recovered_node.list_balances().spendable_onchain_balance_sats, + premine_amount_sat * 2, + "node recovered with rescan_from_height should see funds sent to pre-birthday addresses" + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn build_aborts_on_first_startup_bitcoind_tip_fetch_failure() { // A fresh node pointed at an unreachable bitcoind RPC endpoint must not silently