From df8f084ec3597730568dbe37ad850096598ed625 Mon Sep 17 00:00:00 2001 From: FreeOnlineUser Date: Tue, 10 Mar 2026 21:33:32 +1000 Subject: [PATCH 1/2] Add wallet birthday height for seed recovery on pruned nodes When set_wallet_birthday_height(height) is called, the BDK wallet checkpoint is set to the birthday block instead of the current chain tip. This allows the wallet to sync from the birthday forward, recovering historical UTXOs without scanning from genesis. This is critical for pruned nodes where blocks before the birthday are unavailable, making recovery_mode (which scans from genesis) unusable. Three-way logic: - Birthday set: checkpoint at birthday block - No birthday, no recovery mode: checkpoint at current tip (existing) - Recovery mode without birthday: sync from genesis (existing) Falls back to current tip if the birthday block hash cannot be fetched. Resolves the TODO: 'Use a proper wallet birthday once BDK supports it.' Closes lightningdevkit/ldk-node#818 --- src/builder.rs | 94 +++++++++++++++++++++++++++++++++++++++++++++--- src/chain/mod.rs | 14 ++++++++ 2 files changed, 103 insertions(+), 5 deletions(-) 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/mod.rs b/src/chain/mod.rs index 49c011a78..f2fc8f4b4 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,19 @@ 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(|_| ()) + }, + _ => Err(()), + } + } + pub(crate) fn registered_txids(&self) -> Vec { self.registered_txids.lock().unwrap().clone() } From 5f532f9e1887f4721b8ccd9d8c6d174af9f4e944 Mon Sep 17 00:00:00 2001 From: FreeOnlineUser Date: Tue, 10 Mar 2026 22:36:08 +1000 Subject: [PATCH 2/2] Support all chain sources for wallet birthday block hash lookup Extend get_block_hash_by_height to work with Esplora and Electrum in addition to bitcoind. Esplora uses its native get_block_hash API. Electrum uses block_header_raw and extracts the hash from the header. For Electrum, if the runtime client hasn't started yet (called during build), a temporary connection is created for the lookup. --- src/chain/electrum.rs | 30 ++++++++++++++++++++++++++++++ src/chain/esplora.rs | 6 ++++++ src/chain/mod.rs | 7 ++++++- 3 files changed, 42 insertions(+), 1 deletion(-) 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 f2fc8f4b4..4237e6bad 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -224,7 +224,12 @@ impl ChainSource { let utxo_source = bitcoind_chain_source.as_utxo_source(); utxo_source.get_block_hash_by_height(height).await.map_err(|_| ()) }, - _ => 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 + }, } }