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 03ded494f..03858ff27 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,27 @@ 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). + /// + /// 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 + } + /// 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 +889,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 +1188,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,8 +1390,9 @@ 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: bool, + wallet_birthday_height: Option, seed_bytes: [u8; 64], runtime: Arc, + logger: Arc, kv_store: Arc, ) -> Result { optionally_install_rustls_cryptoprovider(); @@ -1576,10 +1609,50 @@ 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) => { + // 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 {}: {}", + birthday_height, + e + ); + return Err(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 +1667,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 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 cb8541be6..ad471659c 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -15,6 +15,7 @@ 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 crate::chain::electrum::ElectrumChainSource; @@ -214,6 +215,29 @@ 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(|_| 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), + } + } + pub(crate) fn registered_txids(&self) -> Vec { self.registered_txids.lock().expect("lock").clone() } 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;