diff --git a/src/builder.rs b/src/builder.rs index 7641a767d..851e27171 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -69,7 +69,7 @@ use crate::liquidity::{ LSPS1ClientConfig, LSPS2ClientConfig, LSPS2ServiceConfig, LiquiditySourceBuilder, }; use crate::lnurl_auth::LnurlAuth; -use crate::logger::{log_error, LdkLogger, LogLevel, LogWriter, Logger}; +use crate::logger::{log_error, log_info, LdkLogger, LogLevel, LogWriter, Logger}; use crate::message_handler::NodeCustomMessageHandler; use crate::payment::asynchronous::om_mailbox::OnionMessageMailbox; use crate::peer_store::PeerStore; @@ -247,6 +247,7 @@ pub struct NodeBuilder { runtime_handle: Option, pathfinding_scores_sync_config: Option, recovery_mode: bool, + wallet_birthday_height: Option, } impl NodeBuilder { @@ -265,6 +266,7 @@ impl NodeBuilder { let runtime_handle = None; let pathfinding_scores_sync_config = None; let recovery_mode = false; + let wallet_birthday_height = None; Self { config, chain_data_source_config, @@ -275,6 +277,7 @@ impl NodeBuilder { async_payments_role: None, pathfinding_scores_sync_config, recovery_mode, + wallet_birthday_height, } } @@ -559,6 +562,22 @@ impl NodeBuilder { self } + /// Sets the wallet birthday height for seed recovery on pruned nodes. + /// + /// When set, the on-chain wallet will start scanning from the given block height + /// instead of the current chain tip. This allows recovery of historical funds + /// without scanning from genesis, which is critical for pruned nodes where + /// early blocks are unavailable. + /// + /// The birthday height should be set to a block height at or before the wallet's + /// first transaction. If unknown, use a conservative estimate. + /// + /// This only takes effect when creating a new wallet (not when loading existing state). + pub fn set_wallet_birthday_height(&mut self, height: u32) -> &mut Self { + self.wallet_birthday_height = Some(height); + self + } + /// Builds a [`Node`] instance with a [`SqliteStore`] backend and according to the options /// previously configured. pub fn build(&self, node_entropy: NodeEntropy) -> Result { @@ -732,6 +751,7 @@ impl NodeBuilder { self.pathfinding_scores_sync_config.as_ref(), self.async_payments_role, self.recovery_mode, + self.wallet_birthday_height, seed_bytes, runtime, logger, @@ -981,6 +1001,13 @@ impl ArcedNodeBuilder { self.inner.write().unwrap().set_wallet_recovery_mode(); } + /// Sets the wallet birthday height for seed recovery on pruned nodes. + /// + /// See [`NodeBuilder::set_wallet_birthday_height`] for details. + pub fn set_wallet_birthday_height(&self, height: u32) { + self.inner.write().unwrap().set_wallet_birthday_height(height); + } + /// Builds a [`Node`] instance with a [`SqliteStore`] backend and according to the options /// previously configured. pub fn build(&self, node_entropy: Arc) -> Result, BuildError> { @@ -1124,7 +1151,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], + async_payments_role: Option, recovery_mode: bool, + wallet_birthday_height: Option, seed_bytes: [u8; 64], runtime: Arc, logger: Arc, kv_store: Arc, ) -> Result { optionally_install_rustls_cryptoprovider(); @@ -1321,10 +1349,65 @@ fn build_with_store_internal( BuildError::WalletSetupFailed })?; - if !recovery_mode { + if let Some(birthday_height) = wallet_birthday_height { + // Wallet birthday: checkpoint at the birthday block so the wallet + // syncs from there, allowing fund recovery on pruned nodes. + let birthday_hash_res = runtime.block_on(async { + chain_source.get_block_hash_by_height(birthday_height).await + }); + match birthday_hash_res { + Ok(birthday_hash) => { + log_info!( + logger, + "Setting wallet checkpoint at birthday height {} ({})", + birthday_height, + birthday_hash + ); + let mut latest_checkpoint = wallet.latest_checkpoint(); + let block_id = bdk_chain::BlockId { + height: birthday_height, + hash: birthday_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 birthday checkpoint: {}", e); + BuildError::WalletSetupFailed + })?; + }, + Err(e) => { + log_error!( + logger, + "Failed to fetch block hash at birthday height {}: {:?}. \ + Falling back to current tip.", + birthday_height, + e + ); + // Fall back to current tip + if let Some(best_block) = chain_tip_opt { + 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 fallback checkpoint: {}", e); + BuildError::WalletSetupFailed + })?; + } + }, + } + } else 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. + // No birthday: insert current tip to avoid resyncing from genesis. let mut latest_checkpoint = wallet.latest_checkpoint(); let block_id = bdk_chain::BlockId { height: best_block.height, @@ -1339,6 +1422,7 @@ fn build_with_store_internal( })?; } } + // else: recovery_mode without birthday syncs from genesis wallet }, }; diff --git a/src/chain/electrum.rs b/src/chain/electrum.rs index 7b08c3845..3b031e295 100644 --- a/src/chain/electrum.rs +++ b/src/chain/electrum.rs @@ -89,6 +89,28 @@ impl ElectrumChainSource { self.electrum_runtime_status.write().unwrap().stop(); } + pub(super) async fn get_block_hash_by_height( + &self, height: u32, + ) -> Result { + // Try the runtime client if started, otherwise create a temporary connection. + let status = self.electrum_runtime_status.read().unwrap(); + if let Some(client) = status.client() { + drop(status); + return client.get_block_hash_by_height(height); + } + drop(status); + + // Runtime not started yet (called during build). Use a temporary client. + let config = ElectrumConfigBuilder::new() + .timeout(Some(self.sync_config.timeouts_config.per_request_timeout_secs)) + .build(); + let client = ElectrumClient::from_config(&self.server_url, config).map_err(|_| ())?; + let header_bytes = client.block_header_raw(height as usize).map_err(|_| ())?; + let header: bitcoin::block::Header = + bitcoin::consensus::deserialize(&header_bytes).map_err(|_| ())?; + Ok(header.block_hash()) + } + pub(crate) async fn sync_onchain_wallet( &self, onchain_wallet: Arc, ) -> Result<(), Error> { @@ -420,6 +442,14 @@ impl ElectrumRuntimeClient { }) } + fn get_block_hash_by_height(&self, height: u32) -> Result { + let header_bytes = + self.electrum_client.block_header_raw(height as usize).map_err(|_| ())?; + let header: bitcoin::block::Header = + bitcoin::consensus::deserialize(&header_bytes).map_err(|_| ())?; + Ok(header.block_hash()) + } + async fn sync_confirmables( &self, confirmables: Vec>, ) -> Result<(), Error> { diff --git a/src/chain/esplora.rs b/src/chain/esplora.rs index 245db72f6..cdf3e3208 100644 --- a/src/chain/esplora.rs +++ b/src/chain/esplora.rs @@ -74,6 +74,12 @@ impl EsploraChainSource { } } + pub(super) async fn get_block_hash_by_height( + &self, height: u32, + ) -> Result { + self.esplora_client.get_block_hash(height).await.map_err(|_| ()) + } + pub(super) async fn sync_onchain_wallet( &self, onchain_wallet: Arc, ) -> Result<(), Error> { diff --git a/src/chain/mod.rs b/src/chain/mod.rs index 49c011a78..4237e6bad 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -17,6 +17,7 @@ use bitcoin::{Script, Txid}; use lightning::chain::{BestBlock, Filter}; use crate::chain::bitcoind::{BitcoindChainSource, UtxoSourceClient}; +use lightning_block_sync::gossip::UtxoSource; use crate::chain::electrum::ElectrumChainSource; use crate::chain::esplora::EsploraChainSource; use crate::config::{ @@ -214,6 +215,24 @@ impl ChainSource { } } + /// Fetches the block hash at the given height from the chain source. + pub(crate) async fn get_block_hash_by_height( + &self, height: u32, + ) -> Result { + match &self.kind { + ChainSourceKind::Bitcoind(bitcoind_chain_source) => { + let utxo_source = bitcoind_chain_source.as_utxo_source(); + utxo_source.get_block_hash_by_height(height).await.map_err(|_| ()) + }, + ChainSourceKind::Esplora(esplora_chain_source) => { + esplora_chain_source.get_block_hash_by_height(height).await + }, + ChainSourceKind::Electrum(electrum_chain_source) => { + electrum_chain_source.get_block_hash_by_height(height).await + }, + } + } + pub(crate) fn registered_txids(&self) -> Vec { self.registered_txids.lock().unwrap().clone() }