Skip to content

Commit df8f084

Browse files
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 #818
1 parent fae2746 commit df8f084

2 files changed

Lines changed: 103 additions & 5 deletions

File tree

src/builder.rs

Lines changed: 89 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ use crate::liquidity::{
6969
LSPS1ClientConfig, LSPS2ClientConfig, LSPS2ServiceConfig, LiquiditySourceBuilder,
7070
};
7171
use crate::lnurl_auth::LnurlAuth;
72-
use crate::logger::{log_error, LdkLogger, LogLevel, LogWriter, Logger};
72+
use crate::logger::{log_error, log_info, LdkLogger, LogLevel, LogWriter, Logger};
7373
use crate::message_handler::NodeCustomMessageHandler;
7474
use crate::payment::asynchronous::om_mailbox::OnionMessageMailbox;
7575
use crate::peer_store::PeerStore;
@@ -247,6 +247,7 @@ pub struct NodeBuilder {
247247
runtime_handle: Option<tokio::runtime::Handle>,
248248
pathfinding_scores_sync_config: Option<PathfindingScoresSyncConfig>,
249249
recovery_mode: bool,
250+
wallet_birthday_height: Option<u32>,
250251
}
251252

252253
impl NodeBuilder {
@@ -265,6 +266,7 @@ impl NodeBuilder {
265266
let runtime_handle = None;
266267
let pathfinding_scores_sync_config = None;
267268
let recovery_mode = false;
269+
let wallet_birthday_height = None;
268270
Self {
269271
config,
270272
chain_data_source_config,
@@ -275,6 +277,7 @@ impl NodeBuilder {
275277
async_payments_role: None,
276278
pathfinding_scores_sync_config,
277279
recovery_mode,
280+
wallet_birthday_height,
278281
}
279282
}
280283

@@ -559,6 +562,22 @@ impl NodeBuilder {
559562
self
560563
}
561564

565+
/// Sets the wallet birthday height for seed recovery on pruned nodes.
566+
///
567+
/// When set, the on-chain wallet will start scanning from the given block height
568+
/// instead of the current chain tip. This allows recovery of historical funds
569+
/// without scanning from genesis, which is critical for pruned nodes where
570+
/// early blocks are unavailable.
571+
///
572+
/// The birthday height should be set to a block height at or before the wallet's
573+
/// first transaction. If unknown, use a conservative estimate.
574+
///
575+
/// This only takes effect when creating a new wallet (not when loading existing state).
576+
pub fn set_wallet_birthday_height(&mut self, height: u32) -> &mut Self {
577+
self.wallet_birthday_height = Some(height);
578+
self
579+
}
580+
562581
/// Builds a [`Node`] instance with a [`SqliteStore`] backend and according to the options
563582
/// previously configured.
564583
pub fn build(&self, node_entropy: NodeEntropy) -> Result<Node, BuildError> {
@@ -732,6 +751,7 @@ impl NodeBuilder {
732751
self.pathfinding_scores_sync_config.as_ref(),
733752
self.async_payments_role,
734753
self.recovery_mode,
754+
self.wallet_birthday_height,
735755
seed_bytes,
736756
runtime,
737757
logger,
@@ -981,6 +1001,13 @@ impl ArcedNodeBuilder {
9811001
self.inner.write().unwrap().set_wallet_recovery_mode();
9821002
}
9831003

1004+
/// Sets the wallet birthday height for seed recovery on pruned nodes.
1005+
///
1006+
/// See [`NodeBuilder::set_wallet_birthday_height`] for details.
1007+
pub fn set_wallet_birthday_height(&self, height: u32) {
1008+
self.inner.write().unwrap().set_wallet_birthday_height(height);
1009+
}
1010+
9841011
/// Builds a [`Node`] instance with a [`SqliteStore`] backend and according to the options
9851012
/// previously configured.
9861013
pub fn build(&self, node_entropy: Arc<NodeEntropy>) -> Result<Arc<Node>, BuildError> {
@@ -1124,7 +1151,8 @@ fn build_with_store_internal(
11241151
gossip_source_config: Option<&GossipSourceConfig>,
11251152
liquidity_source_config: Option<&LiquiditySourceConfig>,
11261153
pathfinding_scores_sync_config: Option<&PathfindingScoresSyncConfig>,
1127-
async_payments_role: Option<AsyncPaymentsRole>, recovery_mode: bool, seed_bytes: [u8; 64],
1154+
async_payments_role: Option<AsyncPaymentsRole>, recovery_mode: bool,
1155+
wallet_birthday_height: Option<u32>, seed_bytes: [u8; 64],
11281156
runtime: Arc<Runtime>, logger: Arc<Logger>, kv_store: Arc<DynStore>,
11291157
) -> Result<Node, BuildError> {
11301158
optionally_install_rustls_cryptoprovider();
@@ -1321,10 +1349,65 @@ fn build_with_store_internal(
13211349
BuildError::WalletSetupFailed
13221350
})?;
13231351

1324-
if !recovery_mode {
1352+
if let Some(birthday_height) = wallet_birthday_height {
1353+
// Wallet birthday: checkpoint at the birthday block so the wallet
1354+
// syncs from there, allowing fund recovery on pruned nodes.
1355+
let birthday_hash_res = runtime.block_on(async {
1356+
chain_source.get_block_hash_by_height(birthday_height).await
1357+
});
1358+
match birthday_hash_res {
1359+
Ok(birthday_hash) => {
1360+
log_info!(
1361+
logger,
1362+
"Setting wallet checkpoint at birthday height {} ({})",
1363+
birthday_height,
1364+
birthday_hash
1365+
);
1366+
let mut latest_checkpoint = wallet.latest_checkpoint();
1367+
let block_id = bdk_chain::BlockId {
1368+
height: birthday_height,
1369+
hash: birthday_hash,
1370+
};
1371+
latest_checkpoint = latest_checkpoint.insert(block_id);
1372+
let update = bdk_wallet::Update {
1373+
chain: Some(latest_checkpoint),
1374+
..Default::default()
1375+
};
1376+
wallet.apply_update(update).map_err(|e| {
1377+
log_error!(logger, "Failed to apply birthday checkpoint: {}", e);
1378+
BuildError::WalletSetupFailed
1379+
})?;
1380+
},
1381+
Err(e) => {
1382+
log_error!(
1383+
logger,
1384+
"Failed to fetch block hash at birthday height {}: {:?}. \
1385+
Falling back to current tip.",
1386+
birthday_height,
1387+
e
1388+
);
1389+
// Fall back to current tip
1390+
if let Some(best_block) = chain_tip_opt {
1391+
let mut latest_checkpoint = wallet.latest_checkpoint();
1392+
let block_id = bdk_chain::BlockId {
1393+
height: best_block.height,
1394+
hash: best_block.block_hash,
1395+
};
1396+
latest_checkpoint = latest_checkpoint.insert(block_id);
1397+
let update = bdk_wallet::Update {
1398+
chain: Some(latest_checkpoint),
1399+
..Default::default()
1400+
};
1401+
wallet.apply_update(update).map_err(|e| {
1402+
log_error!(logger, "Failed to apply fallback checkpoint: {}", e);
1403+
BuildError::WalletSetupFailed
1404+
})?;
1405+
}
1406+
},
1407+
}
1408+
} else if !recovery_mode {
13251409
if let Some(best_block) = chain_tip_opt {
1326-
// Insert the first checkpoint if we have it, to avoid resyncing from genesis.
1327-
// TODO: Use a proper wallet birthday once BDK supports it.
1410+
// No birthday: insert current tip to avoid resyncing from genesis.
13281411
let mut latest_checkpoint = wallet.latest_checkpoint();
13291412
let block_id = bdk_chain::BlockId {
13301413
height: best_block.height,
@@ -1339,6 +1422,7 @@ fn build_with_store_internal(
13391422
})?;
13401423
}
13411424
}
1425+
// else: recovery_mode without birthday syncs from genesis
13421426
wallet
13431427
},
13441428
};

src/chain/mod.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ use bitcoin::{Script, Txid};
1717
use lightning::chain::{BestBlock, Filter};
1818

1919
use crate::chain::bitcoind::{BitcoindChainSource, UtxoSourceClient};
20+
use lightning_block_sync::gossip::UtxoSource;
2021
use crate::chain::electrum::ElectrumChainSource;
2122
use crate::chain::esplora::EsploraChainSource;
2223
use crate::config::{
@@ -214,6 +215,19 @@ impl ChainSource {
214215
}
215216
}
216217

218+
/// Fetches the block hash at the given height from the chain source.
219+
pub(crate) async fn get_block_hash_by_height(
220+
&self, height: u32,
221+
) -> Result<bitcoin::BlockHash, ()> {
222+
match &self.kind {
223+
ChainSourceKind::Bitcoind(bitcoind_chain_source) => {
224+
let utxo_source = bitcoind_chain_source.as_utxo_source();
225+
utxo_source.get_block_hash_by_height(height).await.map_err(|_| ())
226+
},
227+
_ => Err(()),
228+
}
229+
}
230+
217231
pub(crate) fn registered_txids(&self) -> Vec<Txid> {
218232
self.registered_txids.lock().unwrap().clone()
219233
}

0 commit comments

Comments
 (0)