From ae6f3f302ce28b039e87befa755be055c7170e36 Mon Sep 17 00:00:00 2001 From: FreeOnlineUser Date: Tue, 10 Mar 2026 21:33:32 +1000 Subject: [PATCH 1/3] 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 03ded494f..eb3f9e7bc 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -72,7 +72,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; @@ -292,6 +292,7 @@ pub struct NodeBuilder { runtime_handle: Option, pathfinding_scores_sync_config: Option, recovery_mode: bool, + wallet_birthday_height: Option, } impl NodeBuilder { @@ -310,6 +311,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, @@ -320,6 +322,7 @@ impl NodeBuilder { async_payments_role: None, pathfinding_scores_sync_config, recovery_mode, + wallet_birthday_height, } } @@ -625,6 +628,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 { @@ -865,6 +884,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, @@ -1163,6 +1183,13 @@ impl ArcedNodeBuilder { self.inner.write().expect("lock").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> { @@ -1358,7 +1385,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(); @@ -1576,10 +1604,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, @@ -1594,6 +1677,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 cb8541be6..f32b5acec 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -17,6 +17,7 @@ use bitcoin::{Script, Txid}; use lightning::chain::{BlockLocator, 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().expect("lock").clone() } From 87dc125dd5b02bf094357d8afce19d19382290ad Mon Sep 17 00:00:00 2001 From: FreeOnlineUser Date: Tue, 10 Mar 2026 22:36:08 +1000 Subject: [PATCH 2/3] 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 | 32 ++++++++++++++++++++++++++++++++ src/chain/esplora.rs | 6 ++++++ src/chain/mod.rs | 7 ++++++- 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/chain/electrum.rs b/src/chain/electrum.rs index 54e7fff0c..704b5f2e4 100644 --- a/src/chain/electrum.rs +++ b/src/chain/electrum.rs @@ -89,6 +89,30 @@ impl ElectrumChainSource { self.electrum_runtime_status.write().expect("lock").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(Duration::from_secs( + self.sync_config.timeouts_config.per_request_timeout_secs as u64, + ))) + .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> { @@ -423,6 +447,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 5825a0984..a09baa6f7 100644 --- a/src/chain/esplora.rs +++ b/src/chain/esplora.rs @@ -76,6 +76,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 f32b5acec..8a8f75646 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 + }, } } From 3d8a5f142d896b9af557ab6043616bcb8e657901 Mon Sep 17 00:00:00 2001 From: FreeOnlineUser Date: Wed, 3 Jun 2026 21:40:27 +1000 Subject: [PATCH 3/3] Address review feedback on wallet birthday recovery Addresses review on #822: - Fail with BuildError::WalletSetupFailed when the birthday block hash can't be fetched, instead of silently checkpointing at the chain tip. Silent fallback would scan from tip and recover nothing, leaving the user to assume their seed is wrong. - Return a typed Error::ChainAccessFailed from ChainSource::get_block_hash_by_height instead of Result<_, ()>, and mirror the variant in the UDL Error enum. - Expose set_wallet_birthday_height in the UniFFI bindings. - Document that the birthday height takes precedence over recovery mode. - Add an integration test (onchain_wallet_recovery_with_birthday) that recovers funds from a wallet birthday over random_chain_source, plus the TestConfig plumbing for it. - Run cargo fmt and group the lightning_block_sync import with the external crates. Co-Authored-By: Joe (Claude Opus 4.8) --- bindings/ldk_node.udl | 2 ++ src/builder.rs | 40 ++++++++++++------------------- src/chain/mod.rs | 23 +++++++++++------- src/error.rs | 3 +++ tests/common/mod.rs | 7 ++++++ tests/integration_tests_rust.rs | 42 +++++++++++++++++++++++++++++++++ 6 files changed, 83 insertions(+), 34 deletions(-) diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index 7e9e61f5d..2ddd1abd7 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -61,6 +61,7 @@ interface Builder { [Throws=BuildError] void set_async_payments_role(AsyncPaymentsRole? role); void set_wallet_recovery_mode(); + void set_wallet_birthday_height(u32 height); [Throws=BuildError] Node build(NodeEntropy node_entropy); [Throws=BuildError] @@ -194,6 +195,7 @@ enum NodeError { "OnchainTxSigningFailed", "TxSyncFailed", "TxSyncTimeout", + "ChainAccessFailed", "GossipUpdateFailed", "GossipUpdateTimeout", "LiquidityRequestFailed", diff --git a/src/builder.rs b/src/builder.rs index eb3f9e7bc..03858ff27 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -639,6 +639,11 @@ impl NodeBuilder { /// first transaction. If unknown, use a conservative estimate. /// /// This only takes effect when creating a new wallet (not when loading existing state). + /// + /// If [`set_wallet_recovery_mode`] is also set, the birthday height takes precedence and + /// the wallet is checkpointed at the birthday block. + /// + /// [`set_wallet_recovery_mode`]: Self::set_wallet_recovery_mode pub fn set_wallet_birthday_height(&mut self, height: u32) -> &mut Self { self.wallet_birthday_height = Some(height); self @@ -1386,8 +1391,8 @@ fn build_with_store_internal( liquidity_source_config: Option<&LiquiditySourceConfig>, pathfinding_scores_sync_config: Option<&PathfindingScoresSyncConfig>, async_payments_role: Option, recovery_mode: bool, - wallet_birthday_height: Option, seed_bytes: [u8; 64], - runtime: Arc, logger: Arc, kv_store: Arc, + wallet_birthday_height: Option, seed_bytes: [u8; 64], runtime: Arc, + logger: Arc, kv_store: Arc, ) -> Result { optionally_install_rustls_cryptoprovider(); @@ -1619,10 +1624,8 @@ fn build_with_store_internal( birthday_hash ); let mut latest_checkpoint = wallet.latest_checkpoint(); - let block_id = bdk_chain::BlockId { - height: birthday_height, - hash: birthday_hash, - }; + 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), @@ -1634,30 +1637,17 @@ fn build_with_store_internal( })?; }, Err(e) => { + // A birthday was explicitly set but we couldn't fetch the block hash + // for it. Silently checkpointing at the chain tip would defeat the + // feature: the wallet would scan from tip and recover nothing, leaving + // the user to assume their seed is wrong. Fail loudly instead. log_error!( logger, - "Failed to fetch block hash at birthday height {}: {:?}. \ - Falling back to current tip.", + "Failed to fetch block hash at birthday height {}: {}", 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 - })?; - } + return Err(BuildError::WalletSetupFailed); }, } } else if !recovery_mode { diff --git a/src/chain/mod.rs b/src/chain/mod.rs index 8a8f75646..ad471659c 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -15,9 +15,9 @@ use std::time::Duration; use bitcoin::{Script, Txid}; use lightning::chain::{BlockLocator, Filter}; +use lightning_block_sync::gossip::UtxoSource; 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::{ @@ -218,18 +218,23 @@ 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 { + ) -> 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 + utxo_source + .get_block_hash_by_height(height) + .await + .map_err(|_| Error::ChainAccessFailed) }, + ChainSourceKind::Esplora(esplora_chain_source) => esplora_chain_source + .get_block_hash_by_height(height) + .await + .map_err(|_| Error::ChainAccessFailed), + ChainSourceKind::Electrum(electrum_chain_source) => electrum_chain_source + .get_block_hash_by_height(height) + .await + .map_err(|_| Error::ChainAccessFailed), } } diff --git a/src/error.rs b/src/error.rs index d07212b00..7bde01653 100644 --- a/src/error.rs +++ b/src/error.rs @@ -63,6 +63,8 @@ pub enum Error { TxSyncFailed, /// A transaction sync operation timed out. TxSyncTimeout, + /// Accessing the chain source to look up a block failed. + ChainAccessFailed, /// A gossip updating operation failed. GossipUpdateFailed, /// A gossip updating operation timed out. @@ -171,6 +173,7 @@ impl fmt::Display for Error { Self::OnchainTxSigningFailed => write!(f, "Failed to sign given transaction."), Self::TxSyncFailed => write!(f, "Failed to sync transactions."), Self::TxSyncTimeout => write!(f, "Syncing transactions timed out."), + Self::ChainAccessFailed => write!(f, "Failed to access the chain source."), Self::GossipUpdateFailed => write!(f, "Failed to update gossip data."), Self::GossipUpdateTimeout => write!(f, "Updating gossip data timed out."), Self::LiquidityRequestFailed => write!(f, "Failed to request inbound liquidity."), diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 30d9a4387..b927711fc 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -433,6 +433,7 @@ pub(crate) struct TestConfig { pub node_entropy: NodeEntropy, pub async_payments_role: Option, pub recovery_mode: bool, + pub wallet_birthday_height: Option, } impl Default for TestConfig { @@ -445,6 +446,7 @@ impl Default for TestConfig { let node_entropy = NodeEntropy::from_bip39_mnemonic(mnemonic, None); let async_payments_role = None; let recovery_mode = false; + let wallet_birthday_height = None; TestConfig { node_config, log_writer, @@ -452,6 +454,7 @@ impl Default for TestConfig { node_entropy, async_payments_role, recovery_mode, + wallet_birthday_height, } } } @@ -587,6 +590,10 @@ pub(crate) fn setup_node(chain_source: &TestChainSource, config: TestConfig) -> builder.set_wallet_recovery_mode(); } + if let Some(birthday_height) = config.wallet_birthday_height { + builder.set_wallet_birthday_height(birthday_height); + } + let node = match config.store_type { TestStoreType::TestSyncStore => { let kv_store = TestSyncStore::new(config.node_config.storage_dir_path.into()); diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 1ea6c4584..9e6eca5bc 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -743,6 +743,48 @@ async fn onchain_wallet_recovery() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn onchain_wallet_recovery_with_birthday() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + + let chain_source = random_chain_source(&bitcoind, &electrsd); + + 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; + + // Record the tip before funding. The restored node uses this as its wallet + // birthday, so the deposit lands in a block above the birthday checkpoint. + let birthday_height = bitcoind.client.get_blockchain_info().unwrap().blocks as u32; + + let addr = original_node.onchain_payment().new_address().unwrap(); + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![addr], + Amount::from_sat(premine_amount_sat), + ) + .await; + original_node.sync_wallets().unwrap(); + assert_eq!(original_node.list_balances().spendable_onchain_balance_sats, premine_amount_sat); + + original_node.stop().unwrap(); + drop(original_node); + + // Restore from the same seed using a wallet birthday instead of recovery + // mode: the wallet checkpoints at the birthday block and syncs forward, + // recovering the funds without scanning from genesis. + let mut recovered_config = random_config(true); + recovered_config.node_entropy = original_node_entropy; + recovered_config.wallet_birthday_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); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_rbf_via_mempool() { run_rbf_test(false).await;