diff --git a/CHANGELOG.md b/CHANGELOG.md index c9f15e61f..e7e012a14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ - Users of the VSS storage backend must upgrade their VSS server to at least version `v0.1.0-alpha.0` before upgrading LDK Node. +## Feature and API updates +- The Bitcoin Core RPC and REST chain-source builder methods now accept an optional + `wallet_rescan_from_height` argument. Passing a height lets fresh wallets rescan from a known + birthday block instead of checkpointing at the current tip, which is useful when restoring a + wallet on a pruned node where the full history is unavailable but the wallet birthday height is + known. Existing wallets are not rewound, and future heights fail the build. Passing `Some(0)` + rescans from genesis; passing `None` keeps the default current-tip checkpoint behavior. (#884) +- `EsploraSyncConfig` and `ElectrumSyncConfig` now support `force_wallet_full_scan`. When set, + the on-chain wallet keeps using BDK `full_scan` instead of incremental sync until a full scan + succeeds, allowing restored wallets to rediscover funds sent to previously-unknown addresses. + +## 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. (#884) + # 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/bindings/ldk_node.udl b/bindings/ldk_node.udl index 851583c5a..0fddd771b 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -38,8 +38,8 @@ interface Builder { constructor(Config config); void set_chain_source_esplora(string server_url, EsploraSyncConfig? config); void set_chain_source_electrum(string server_url, ElectrumSyncConfig? config); - void set_chain_source_bitcoind_rpc(string rpc_host, u16 rpc_port, string rpc_user, string rpc_password); - void set_chain_source_bitcoind_rest(string rest_host, u16 rest_port, string rpc_host, u16 rpc_port, string rpc_user, string rpc_password); + void set_chain_source_bitcoind_rpc(string rpc_host, u16 rpc_port, string rpc_user, string rpc_password, u32? wallet_rescan_from_height); + void set_chain_source_bitcoind_rest(string rest_host, u16 rest_port, string rpc_host, u16 rpc_port, string rpc_user, string rpc_password, u32? wallet_rescan_from_height); void set_gossip_source_p2p(); void set_gossip_source_rgs(string rgs_server_url); void set_pathfinding_scores_source(string url); @@ -59,7 +59,6 @@ interface Builder { void set_node_alias(string node_alias); [Throws=BuildError] void set_async_payments_role(AsyncPaymentsRole? role); - void set_wallet_recovery_mode(); [Throws=BuildError] Node build(NodeEntropy node_entropy); [Throws=BuildError] diff --git a/src/builder.rs b/src/builder.rs index d142f51af..6f651676e 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -105,6 +105,7 @@ enum ChainDataSourceConfig { rpc_user: String, rpc_password: String, rest_client_config: Option, + wallet_rescan_from_height: Option, }, } @@ -196,6 +197,15 @@ 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, + /// The configured wallet rescan height is above the current chain tip. + WalletRescanHeightTooHigh, } impl fmt::Display for BuildError { @@ -233,6 +243,15 @@ 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." + ) + }, + Self::WalletRescanHeightTooHigh => { + write!(f, "Wallet rescan height is above the current chain tip.") + }, } } } @@ -287,7 +306,6 @@ pub struct NodeBuilder { async_payments_role: Option, runtime_handle: Option, pathfinding_scores_sync_config: Option, - recovery_mode: bool, } impl NodeBuilder { @@ -305,7 +323,6 @@ impl NodeBuilder { let log_writer_config = None; let runtime_handle = None; let pathfinding_scores_sync_config = None; - let recovery_mode = false; Self { config, chain_data_source_config, @@ -315,7 +332,6 @@ impl NodeBuilder { runtime_handle, async_payments_role: None, pathfinding_scores_sync_config, - recovery_mode, } } @@ -380,8 +396,13 @@ impl NodeBuilder { /// ## Parameters: /// * `rpc_host`, `rpc_port`, `rpc_user`, `rpc_password` - Required parameters for the Bitcoin Core RPC /// connection. + /// * `wallet_rescan_from_height` - Optional wallet birthday height to rescan from on first + /// startup, before wallet state exists. Existing wallets are not rewound. The height must + /// be at or below the current tip. Passing `Some(0)` rescans from genesis; passing `None` + /// checkpoints at the current tip. pub fn set_chain_source_bitcoind_rpc( &mut self, rpc_host: String, rpc_port: u16, rpc_user: String, rpc_password: String, + wallet_rescan_from_height: Option, ) -> &mut Self { self.chain_data_source_config = Some(ChainDataSourceConfig::Bitcoind { rpc_host, @@ -389,6 +410,7 @@ impl NodeBuilder { rpc_user, rpc_password, rest_client_config: None, + wallet_rescan_from_height, }); self } @@ -402,9 +424,13 @@ impl NodeBuilder { /// * `rest_host`, `rest_port` - Required parameters for the Bitcoin Core REST connection. /// * `rpc_host`, `rpc_port`, `rpc_user`, `rpc_password` - Required parameters for the Bitcoin Core RPC /// connection + /// * `wallet_rescan_from_height` - Optional wallet birthday height to rescan from on first + /// startup, before wallet state exists. Existing wallets are not rewound. The height must + /// be at or below the current tip. Passing `Some(0)` rescans from genesis; passing `None` + /// checkpoints at the current tip. pub fn set_chain_source_bitcoind_rest( &mut self, rest_host: String, rest_port: u16, rpc_host: String, rpc_port: u16, - rpc_user: String, rpc_password: String, + rpc_user: String, rpc_password: String, wallet_rescan_from_height: Option, ) -> &mut Self { self.chain_data_source_config = Some(ChainDataSourceConfig::Bitcoind { rpc_host, @@ -412,6 +438,7 @@ impl NodeBuilder { rpc_user, rpc_password, rest_client_config: Some(BitcoindRestClientConfig { rest_host, rest_port }), + wallet_rescan_from_height, }); self @@ -602,16 +629,6 @@ impl NodeBuilder { Ok(self) } - /// Configures the [`Node`] to resync chain data from genesis on first startup, recovering any - /// historical wallet funds. - /// - /// 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; - self - } - /// Builds a [`Node`] instance with a [`SqliteStore`] backend and according to the options /// previously configured. pub fn build(&self, node_entropy: NodeEntropy) -> Result { @@ -852,7 +869,6 @@ impl NodeBuilder { self.liquidity_source_config.as_ref(), self.pathfinding_scores_sync_config.as_ref(), self.async_payments_role, - self.recovery_mode, seed_bytes, runtime, logger, @@ -966,14 +982,20 @@ impl ArcedNodeBuilder { /// ## Parameters: /// * `rpc_host`, `rpc_port`, `rpc_user`, `rpc_password` - Required parameters for the Bitcoin Core RPC /// connection. + /// * `wallet_rescan_from_height` - Optional wallet birthday height to rescan from on first + /// startup, before wallet state exists. Existing wallets are not rewound. The height must + /// be at or below the current tip. Passing `Some(0)` rescans from genesis; passing `None` + /// checkpoints at the current tip. pub fn set_chain_source_bitcoind_rpc( &self, rpc_host: String, rpc_port: u16, rpc_user: String, rpc_password: String, + wallet_rescan_from_height: Option, ) { self.inner.write().expect("lock").set_chain_source_bitcoind_rpc( rpc_host, rpc_port, rpc_user, rpc_password, + wallet_rescan_from_height, ); } @@ -986,9 +1008,13 @@ impl ArcedNodeBuilder { /// * `rest_host`, `rest_port` - Required parameters for the Bitcoin Core REST connection. /// * `rpc_host`, `rpc_port`, `rpc_user`, `rpc_password` - Required parameters for the Bitcoin Core RPC /// connection + /// * `wallet_rescan_from_height` - Optional wallet birthday height to rescan from on first + /// startup, before wallet state exists. Existing wallets are not rewound. The height must + /// be at or below the current tip. Passing `Some(0)` rescans from genesis; passing `None` + /// checkpoints at the current tip. pub fn set_chain_source_bitcoind_rest( &self, rest_host: String, rest_port: u16, rpc_host: String, rpc_port: u16, - rpc_user: String, rpc_password: String, + rpc_user: String, rpc_password: String, wallet_rescan_from_height: Option, ) { self.inner.write().expect("lock").set_chain_source_bitcoind_rest( rest_host, @@ -997,6 +1023,7 @@ impl ArcedNodeBuilder { rpc_port, rpc_user, rpc_password, + wallet_rescan_from_height, ); } @@ -1139,15 +1166,6 @@ 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. - /// - /// 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(); - } - /// Builds a [`Node`] instance with a [`SqliteStore`] backend and according to the options /// previously configured. pub fn build(&self, node_entropy: Arc) -> Result, BuildError> { @@ -1343,8 +1361,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, seed_bytes: [u8; 64], runtime: Arc, + logger: Arc, kv_store: Arc, ) -> Result { optionally_install_rustls_cryptoprovider(); @@ -1460,6 +1478,7 @@ fn build_with_store_internal( rpc_user, rpc_password, rest_client_config, + .. }) => match rest_client_config { Some(rest_client_config) => runtime.block_on(async { ChainSource::new_bitcoind_rest( @@ -1513,6 +1532,12 @@ fn build_with_store_internal( }, }; let chain_source = Arc::new(chain_source); + let wallet_rescan_from_height = match chain_data_source_config { + Some(ChainDataSourceConfig::Bitcoind { wallet_rescan_from_height, .. }) => { + *wallet_rescan_from_height + }, + _ => None, + }; // Initialize the on-chain wallet and chain access let xprv = bitcoin::bip32::Xpriv::new_master(config.network, &seed_bytes).map_err(|e| { @@ -1555,8 +1580,33 @@ fn build_with_store_internal( }, })?; let bdk_wallet = match wallet_opt { - Some(wallet) => wallet, + Some(wallet) => { + // `wallet_rescan_from_height`, when set, is fresh-wallet-only. Rewinding a + // persisted wallet is not just replacing BDK's best block: its local-chain and + // tx-graph changesets are already persisted, and LDK state may also have synced + // to a later tip. A safe rewind needs an explicit recovery flow that invalidates + // all dependent state before replaying blocks. + 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. + if wallet_rescan_from_height.is_none() + && chain_tip_opt.is_none() + && matches!(chain_data_source_config, Some(ChainDataSourceConfig::Bitcoind { .. })) + { + 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 = runtime .block_on(async { BdkWallet::create(descriptor, change_descriptor) @@ -1569,23 +1619,67 @@ 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. If the + // bitcoind config provides a wallet rescan height, resolve that block and use it as + // the checkpoint. Otherwise, use the current chain tip to avoid any rescan. + let checkpoint_block = match wallet_rescan_from_height { + None => chain_tip_opt, + Some(height) => { + if let Some(chain_tip) = chain_tip_opt { + if height > chain_tip.height { + log_error!( + logger, + "Wallet rescan height {} is above current chain tip {}.", + height, + chain_tip.height + ); + return Err(BuildError::WalletRescanHeightTooHigh); + } + } + + let utxo_source = chain_source.as_utxo_source().ok_or_else(|| { + log_error!( + logger, + "Wallet rescan height requested 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(BlockLocator::new(hash, height)), + Err(e) => { + log_error!( + logger, + "Failed to resolve block hash at height {} for wallet rescan: {:?}", + height, + e, + ); + return Err(BuildError::WalletSetupFailed); + }, + } + }, + }; + + 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 }, diff --git a/src/chain/electrum.rs b/src/chain/electrum.rs index 7406f06b4..23c930d98 100644 --- a/src/chain/electrum.rs +++ b/src/chain/electrum.rs @@ -6,6 +6,7 @@ // accordance with one or both of these licenses. use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, RwLock}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -50,6 +51,7 @@ pub(super) struct ElectrumChainSource { config: Arc, logger: Arc, node_metrics: Arc, + force_wallet_full_scan: AtomicBool, } impl ElectrumChainSource { @@ -61,6 +63,7 @@ impl ElectrumChainSource { let electrum_runtime_status = RwLock::new(ElectrumRuntimeStatus::new()); let onchain_wallet_sync_status = Mutex::new(WalletSyncStatus::Completed); let lightning_wallet_sync_status = Mutex::new(WalletSyncStatus::Completed); + let force_wallet_full_scan = AtomicBool::new(sync_config.force_wallet_full_scan); Self { server_url, sync_config, @@ -72,6 +75,7 @@ impl ElectrumChainSource { config, logger: Arc::clone(&logger), node_metrics, + force_wallet_full_scan, } } @@ -125,9 +129,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, unless a forced full scan is still pending. + let has_prior_sync = self.node_metrics.read().expect("lock").latest_onchain_wallet_sync_timestamp.is_some(); + let forced_full_scan = self.force_wallet_full_scan.load(Ordering::Acquire); + let incremental_sync = has_prior_sync && !forced_full_scan; let cached_txs = onchain_wallet.get_cached_txs(); @@ -160,6 +166,9 @@ impl ElectrumChainSource { .await }; + if forced_full_scan && res.is_ok() { + self.force_wallet_full_scan.store(false, Ordering::Release); + } res } diff --git a/src/chain/esplora.rs b/src/chain/esplora.rs index eb23a395d..0754986e8 100644 --- a/src/chain/esplora.rs +++ b/src/chain/esplora.rs @@ -6,6 +6,7 @@ // accordance with one or both of these licenses. use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -38,6 +39,7 @@ pub(super) struct EsploraChainSource { config: Arc, logger: Arc, node_metrics: Arc, + force_wallet_full_scan: AtomicBool, } impl EsploraChainSource { @@ -62,6 +64,7 @@ impl EsploraChainSource { let onchain_wallet_sync_status = Mutex::new(WalletSyncStatus::Completed); let lightning_wallet_sync_status = Mutex::new(WalletSyncStatus::Completed); + let force_wallet_full_scan = AtomicBool::new(sync_config.force_wallet_full_scan); Ok(Self { sync_config, esplora_client, @@ -73,6 +76,7 @@ impl EsploraChainSource { config, logger, node_metrics, + force_wallet_full_scan, }) } @@ -101,9 +105,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, unless a forced full scan is still pending. + let has_prior_sync = self.node_metrics.read().expect("lock").latest_onchain_wallet_sync_timestamp.is_some(); + let forced_full_scan = self.force_wallet_full_scan.load(Ordering::Acquire); + let incremental_sync = has_prior_sync && !forced_full_scan; macro_rules! get_and_apply_wallet_update { ($sync_future: expr) => {{ @@ -177,7 +183,7 @@ impl EsploraChainSource { }} } - if incremental_sync { + let res = if incremental_sync { let sync_request = onchain_wallet.get_incremental_sync_request(); let wallet_sync_timeout_fut = tokio::time::timeout( Duration::from_secs( @@ -199,7 +205,11 @@ impl EsploraChainSource { ), ); get_and_apply_wallet_update!(wallet_sync_timeout_fut) + }; + if forced_full_scan && res.is_ok() { + self.force_wallet_full_scan.store(false, Ordering::Release); } + res } pub(super) async fn sync_lightning_wallet( diff --git a/src/config.rs b/src/config.rs index 558a4d061..ad1b91181 100644 --- a/src/config.rs +++ b/src/config.rs @@ -506,6 +506,11 @@ pub struct EsploraSyncConfig { pub background_sync_config: Option, /// Sync timeouts configuration. pub timeouts_config: SyncTimeoutsConfig, + /// Whether to force BDK full scans until one succeeds. + /// + /// This can be useful when restoring a wallet from seed on a node that has already synced + /// before, but may be missing funds sent to previously-unknown addresses. + pub force_wallet_full_scan: bool, } impl Default for EsploraSyncConfig { @@ -513,6 +518,7 @@ impl Default for EsploraSyncConfig { Self { background_sync_config: Some(BackgroundSyncConfig::default()), timeouts_config: SyncTimeoutsConfig::default(), + force_wallet_full_scan: false, } } } @@ -533,6 +539,11 @@ pub struct ElectrumSyncConfig { pub background_sync_config: Option, /// Sync timeouts configuration. pub timeouts_config: SyncTimeoutsConfig, + /// Whether to force BDK full scans until one succeeds. + /// + /// This can be useful when restoring a wallet from seed on a node that has already synced + /// before, but may be missing funds sent to previously-unknown addresses. + pub force_wallet_full_scan: bool, } impl Default for ElectrumSyncConfig { @@ -540,6 +551,7 @@ impl Default for ElectrumSyncConfig { Self { background_sync_config: Some(BackgroundSyncConfig::default()), timeouts_config: SyncTimeoutsConfig::default(), + force_wallet_full_scan: false, } } } diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 1f5753e55..8c3ba72bd 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -435,7 +435,8 @@ pub(crate) struct TestConfig { pub store_type: TestStoreType, pub node_entropy: NodeEntropy, pub async_payments_role: Option, - pub recovery_mode: bool, + pub wallet_rescan_from_height: Option, + pub force_wallet_full_scan: bool, } impl Default for TestConfig { @@ -447,14 +448,16 @@ 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 wallet_rescan_from_height = None; + let force_wallet_full_scan = false; TestConfig { node_config, log_writer, store_type, node_entropy, async_payments_role, - recovery_mode, + wallet_rescan_from_height, + force_wallet_full_scan, } } } @@ -537,12 +540,14 @@ pub(crate) fn setup_node(chain_source: &TestChainSource, config: TestConfig) -> let esplora_url = format!("http://{}", electrsd.esplora_url.as_ref().unwrap()); let mut sync_config = EsploraSyncConfig::default(); sync_config.background_sync_config = None; + sync_config.force_wallet_full_scan = config.force_wallet_full_scan; builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); }, TestChainSource::Electrum(electrsd) => { let electrum_url = format!("tcp://{}", electrsd.electrum_url); let mut sync_config = ElectrumSyncConfig::default(); sync_config.background_sync_config = None; + sync_config.force_wallet_full_scan = config.force_wallet_full_scan; builder.set_chain_source_electrum(electrum_url.clone(), Some(sync_config)); }, TestChainSource::BitcoindRpcSync(bitcoind) => { @@ -551,7 +556,13 @@ pub(crate) fn setup_node(chain_source: &TestChainSource, config: TestConfig) -> let values = bitcoind.params.get_cookie_values().unwrap().unwrap(); let rpc_user = values.user; let rpc_password = values.password; - builder.set_chain_source_bitcoind_rpc(rpc_host, rpc_port, rpc_user, rpc_password); + builder.set_chain_source_bitcoind_rpc( + rpc_host, + rpc_port, + rpc_user, + rpc_password, + config.wallet_rescan_from_height, + ); }, TestChainSource::BitcoindRestSync(bitcoind) => { let rpc_host = bitcoind.params.rpc_socket.ip().to_string(); @@ -568,6 +579,7 @@ pub(crate) fn setup_node(chain_source: &TestChainSource, config: TestConfig) -> rpc_port, rpc_user, rpc_password, + config.wallet_rescan_from_height, ); }, } @@ -586,10 +598,6 @@ 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(); - } - let node = match config.store_type { TestStoreType::TestSyncStore => { let kv_store = TestSyncStore::new(config.node_config.storage_dir_path.into()); @@ -601,10 +609,6 @@ pub(crate) fn setup_node(chain_source: &TestChainSource, config: TestConfig) -> }, }; - 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 fab73ed0c..1eb645b0c 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; @@ -46,7 +46,7 @@ use log::LevelFilter; #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn channel_full_cycle() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); - let chain_source = random_chain_source(&bitcoind, &electrsd); + let chain_source = TestChainSource::BitcoindRpcSync(&bitcoind); let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); do_channel_full_cycle( node_a, @@ -805,7 +805,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.wallet_rescan_from_height = Some(0); let recovered_node = setup_node(&chain_source, recovered_config); recovered_node.sync_wallets().unwrap(); @@ -838,6 +838,228 @@ async fn onchain_wallet_recovery() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn onchain_wallet_force_full_scan_rediscovers_esplora_funds() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + premine_blocks(&bitcoind.client, &electrsd.client).await; + + let address_source_config = random_config(true); + let node_entropy = address_source_config.node_entropy; + let address_source_node = setup_node(&chain_source, address_source_config); + let addr_1 = address_source_node.onchain_payment().new_address().unwrap(); + let addr_2 = address_source_node.onchain_payment().new_address().unwrap(); + address_source_node.stop().unwrap(); + drop(address_source_node); + + let premine_amount_sat = 100_000; + let mut stale_config = random_config(true); + stale_config.node_entropy = node_entropy; + stale_config.store_type = TestStoreType::Sqlite; + let stale_node = setup_node(&chain_source, stale_config.clone()); + stale_node.sync_wallets().unwrap(); + assert_eq!(stale_node.list_balances().spendable_onchain_balance_sats, 0); + stale_node.stop().unwrap(); + drop(stale_node); + + 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; + + let normal_node = setup_node(&chain_source, stale_config.clone()); + normal_node.sync_wallets().unwrap(); + assert_eq!( + normal_node.list_balances().spendable_onchain_balance_sats, + 0, + "normal incremental sync should not rediscover previously-unknown addresses" + ); + normal_node.stop().unwrap(); + drop(normal_node); + + stale_config.force_wallet_full_scan = true; + let recovered_node = setup_node(&chain_source, stale_config); + recovered_node.sync_wallets().unwrap(); + assert_eq!( + recovered_node.list_balances().spendable_onchain_balance_sats, + premine_amount_sat * 2, + "forced full scan should rediscover funds sent to previously-unknown addresses" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn onchain_wallet_recovery_rescans_from_birthday_height() { + // End-to-end test for `wallet_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 and no rescan height. 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 `wallet_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 rescan height. 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 rescan height should not find funds below its wallet birthday" + ); + pinned_node.stop().unwrap(); + drop(pinned_node); + + // Step 6: restart with a rescan height set to the birthday height. Funds must be + // re-discovered. + let mut recovered_config = random_config(true); + recovered_config.node_entropy = original_node_entropy; + recovered_config.wallet_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_fails_when_wallet_rescan_height_is_above_tip() { + let (bitcoind, _electrsd) = setup_bitcoind_and_electrsd(); + let current_tip_height: u32 = bitcoind + .client + .get_blockchain_info() + .expect("failed to get blockchain info") + .blocks + .try_into() + .unwrap(); + + let config = random_config(false); + let entropy = config.node_entropy; + + setup_builder!(builder, config.node_config); + let values = bitcoind.params.get_cookie_values().unwrap().unwrap(); + builder.set_chain_source_bitcoind_rpc( + bitcoind.params.rpc_socket.ip().to_string(), + bitcoind.params.rpc_socket.port(), + values.user, + values.password, + Some(current_tip_height + 1), + ); + + match builder.build(entropy.into()) { + Err(err) => { + assert_eq!(err, BuildError::WalletRescanHeightTooHigh); + assert_eq!(err.to_string(), "Wallet rescan height is above the current chain tip."); + }, + Ok(_) => panic!("expected build to fail for future wallet rescan height"), + } +} + +#[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(), + None, + ); + + 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;