Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions bindings/ldk_node.udl
Original file line numberDiff line numberDiff line change
Expand Up@@ -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]
Expand DownExpand Up@@ -194,6 +195,7 @@ enum NodeError {
"OnchainTxSigningFailed",
"TxSyncFailed",
"TxSyncTimeout",
"ChainAccessFailed",
"GossipUpdateFailed",
"GossipUpdateTimeout",
"LiquidityRequestFailed",
Expand Down
86 changes: 80 additions & 6 deletions src/builder.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -292,6 +292,7 @@ pub struct NodeBuilder {
runtime_handle: Option<tokio::runtime::Handle>,
pathfinding_scores_sync_config: Option<PathfindingScoresSyncConfig>,
recovery_mode: bool,
wallet_birthday_height: Option<u32>,
}

impl NodeBuilder {
Expand All@@ -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,
Expand All@@ -320,6 +322,7 @@ impl NodeBuilder {
async_payments_role: None,
pathfinding_scores_sync_config,
recovery_mode,
wallet_birthday_height,
}
}

Expand DownExpand Up@@ -625,6 +628,27 @@ impl NodeBuilder {
self
}

/// Sets the wallet birthday height for seed recovery on pruned nodes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current code makes birthday take precedence over set_wallet_recovery_mode (the if let Some(birthday_height) = … else if !recovery_mode { … } chain in build_with_store_internal). Worth a sentence here noting the precedence so callers who set both know what to expect.
"Use a conservative estimate" is direction-ambiguous. A height after the first tx would miss funds.
Maybe: "use a height earlier than your first transaction, lower is safer than higher."

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documented: the birthday height takes precedence over recovery mode.

///
/// 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<Node, BuildError> {
Expand DownExpand Up@@ -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,
Expand DownExpand Up@@ -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<NodeEntropy>) -> Result<Arc<Node>, BuildError> {
Expand DownExpand Up@@ -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<AsyncPaymentsRole>, recovery_mode: bool, seed_bytes: [u8; 64],
runtime: Arc<Runtime>, logger: Arc<Logger>, kv_store: Arc<DynStore>,
async_payments_role: Option<AsyncPaymentsRole>, recovery_mode: bool,
wallet_birthday_height: Option<u32>, seed_bytes: [u8; 64], runtime: Arc<Runtime>,
logger: Arc<Logger>, kv_store: Arc<DynStore>,
) -> Result<Node, BuildError> {
optionally_install_rustls_cryptoprovider();

Expand DownExpand Up@@ -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);
},
Comment on lines +1639 to +1651

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the user set a wallet birthday they almost certainly don't want to be silently checkpointed at chain tip on a transient hash-fetch failure, that defeats the whole point of the feature. A mobile recovery user would see 0 sats restored, assume their seed is wrong, and likely never look at the logs.
Two safer alternatives:

  1. Return BuildError::WalletSetupFailed so the caller can retry (or fall back at the application layer).
  2. Fall back to no checkpoint, i.e. the recovery_mode genesis-sync behavior, so the user still gets historical coverage at the cost of a full scan.
    I'd lean toward option 1 (surface the error). Falling back to tip is the worst outcome for the user. WDYT?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Now returns BuildError::WalletSetupFailed when a birthday is set but the block hash can't be fetched, rather than silently checkpointing at the tip.

}
} 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,
Expand All@@ -1594,6 +1667,7 @@ fn build_with_store_internal(
})?;
}
}
// else: recovery_mode without birthday syncs from genesis
wallet
},
};
Expand Down
32 changes: 32 additions & 0 deletions src/chain/electrum.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<bitcoin::BlockHash, ()> {
// 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<Wallet>,
) -> Result<(), Error> {
Expand DownExpand Up@@ -423,6 +447,14 @@ impl ElectrumRuntimeClient {
})
}

fn get_block_hash_by_height(&self, height: u32) -> Result<bitcoin::BlockHash, ()> {
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<Arc<dyn Confirm + Sync + Send>>,
) -> Result<(), Error> {
Expand Down
6 changes: 6 additions & 0 deletions src/chain/esplora.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,6 +76,12 @@ impl EsploraChainSource {
})
}

pub(super) async fn get_block_hash_by_height(
&self, height: u32,
) -> Result<bitcoin::BlockHash, ()> {
self.esplora_client.get_block_hash(height).await.map_err(|_| ())
}

pub(super) async fn sync_onchain_wallet(
&self, onchain_wallet: Arc<Wallet>,
) -> Result<(), Error> {
Expand Down
24 changes: 24 additions & 0 deletions src/chain/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every other method on ChainSource in this file returns Result<, Error> (e.g. start at L188, update_fee_rate_estimates at L446). Result<, ()> here drops useful context from all three backends: UtxoSource::get_block_hash_by_height returns BlockSourceResult, esplora_client.get_block_hash returns Result<, EsploraError>, and block_header_raw returns Result<, electrum_client::Error>. Surfacing those via crate::Error would be consistent and would let the call site reason about the failure mode (relevant to the comment on builder.rs:1419-1444).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Returns Error::ChainAccessFailed now, consistent with the other ChainSource methods, and mirrored into the UDL error enum.

&self, height: u32,
) -> Result<bitcoin::BlockHash, Error> {
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<Txid> {
self.registered_txids.lock().expect("lock").clone()
}
Expand Down
3 changes: 3 additions & 0 deletions src/error.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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."),
Expand Down
7 changes: 7 additions & 0 deletions tests/common/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -433,6 +433,7 @@ pub(crate) struct TestConfig {
pub node_entropy: NodeEntropy,
pub async_payments_role: Option<AsyncPaymentsRole>,
pub recovery_mode: bool,
pub wallet_birthday_height: Option<u32>,
}

impl Default for TestConfig {
Expand All@@ -445,13 +446,15 @@ 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,
store_type,
node_entropy,
async_payments_role,
recovery_mode,
wallet_birthday_height,
}
}
}
Expand DownExpand Up@@ -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());
Expand Down
42 changes: 42 additions & 0 deletions tests/integration_tests_rust.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions bindings/ldk_node.udl
Original file line numberDiff line numberDiff line change
Expand Up@@ -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]
Expand DownExpand Up@@ -194,6 +195,7 @@ enum NodeError {
"OnchainTxSigningFailed",
"TxSyncFailed",
"TxSyncTimeout",
"ChainAccessFailed",
"GossipUpdateFailed",
"GossipUpdateTimeout",
"LiquidityRequestFailed",
Expand Down
86 changes: 80 additions & 6 deletions src/builder.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -292,6 +292,7 @@ pub struct NodeBuilder {
runtime_handle: Option<tokio::runtime::Handle>,
pathfinding_scores_sync_config: Option<PathfindingScoresSyncConfig>,
recovery_mode: bool,
wallet_birthday_height: Option<u32>,
}

impl NodeBuilder {
Expand All@@ -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,
Expand All@@ -320,6 +322,7 @@ impl NodeBuilder {
async_payments_role: None,
pathfinding_scores_sync_config,
recovery_mode,
wallet_birthday_height,
}
}

Expand DownExpand Up@@ -625,6 +628,27 @@ impl NodeBuilder {
self
}

/// Sets the wallet birthday height for seed recovery on pruned nodes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current code makes birthday take precedence over set_wallet_recovery_mode (the if let Some(birthday_height) = … else if !recovery_mode { … } chain in build_with_store_internal). Worth a sentence here noting the precedence so callers who set both know what to expect.
"Use a conservative estimate" is direction-ambiguous. A height after the first tx would miss funds.
Maybe: "use a height earlier than your first transaction, lower is safer than higher."

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documented: the birthday height takes precedence over recovery mode.

///
/// 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<Node, BuildError> {
Expand DownExpand Up@@ -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,
Expand DownExpand Up@@ -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<NodeEntropy>) -> Result<Arc<Node>, BuildError> {
Expand DownExpand Up@@ -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<AsyncPaymentsRole>, recovery_mode: bool, seed_bytes: [u8; 64],
runtime: Arc<Runtime>, logger: Arc<Logger>, kv_store: Arc<DynStore>,
async_payments_role: Option<AsyncPaymentsRole>, recovery_mode: bool,
wallet_birthday_height: Option<u32>, seed_bytes: [u8; 64], runtime: Arc<Runtime>,
logger: Arc<Logger>, kv_store: Arc<DynStore>,
) -> Result<Node, BuildError> {
optionally_install_rustls_cryptoprovider();

Expand DownExpand Up@@ -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);
},
Comment on lines +1639 to +1651

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the user set a wallet birthday they almost certainly don't want to be silently checkpointed at chain tip on a transient hash-fetch failure, that defeats the whole point of the feature. A mobile recovery user would see 0 sats restored, assume their seed is wrong, and likely never look at the logs.
Two safer alternatives:

  1. Return BuildError::WalletSetupFailed so the caller can retry (or fall back at the application layer).
  2. Fall back to no checkpoint, i.e. the recovery_mode genesis-sync behavior, so the user still gets historical coverage at the cost of a full scan.
    I'd lean toward option 1 (surface the error). Falling back to tip is the worst outcome for the user. WDYT?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Now returns BuildError::WalletSetupFailed when a birthday is set but the block hash can't be fetched, rather than silently checkpointing at the tip.

}
} 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,
Expand All@@ -1594,6 +1667,7 @@ fn build_with_store_internal(
})?;
}
}
// else: recovery_mode without birthday syncs from genesis
wallet
},
};
Expand Down
32 changes: 32 additions & 0 deletions src/chain/electrum.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<bitcoin::BlockHash, ()> {
// 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<Wallet>,
) -> Result<(), Error> {
Expand DownExpand Up@@ -423,6 +447,14 @@ impl ElectrumRuntimeClient {
})
}

fn get_block_hash_by_height(&self, height: u32) -> Result<bitcoin::BlockHash, ()> {
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<Arc<dyn Confirm + Sync + Send>>,
) -> Result<(), Error> {
Expand Down
6 changes: 6 additions & 0 deletions src/chain/esplora.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,6 +76,12 @@ impl EsploraChainSource {
})
}

pub(super) async fn get_block_hash_by_height(
&self, height: u32,
) -> Result<bitcoin::BlockHash, ()> {
self.esplora_client.get_block_hash(height).await.map_err(|_| ())
}

pub(super) async fn sync_onchain_wallet(
&self, onchain_wallet: Arc<Wallet>,
) -> Result<(), Error> {
Expand Down
24 changes: 24 additions & 0 deletions src/chain/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every other method on ChainSource in this file returns Result<, Error> (e.g. start at L188, update_fee_rate_estimates at L446). Result<, ()> here drops useful context from all three backends: UtxoSource::get_block_hash_by_height returns BlockSourceResult, esplora_client.get_block_hash returns Result<, EsploraError>, and block_header_raw returns Result<, electrum_client::Error>. Surfacing those via crate::Error would be consistent and would let the call site reason about the failure mode (relevant to the comment on builder.rs:1419-1444).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Returns Error::ChainAccessFailed now, consistent with the other ChainSource methods, and mirrored into the UDL error enum.

&self, height: u32,
) -> Result<bitcoin::BlockHash, Error> {
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<Txid> {
self.registered_txids.lock().expect("lock").clone()
}
Expand Down
3 changes: 3 additions & 0 deletions src/error.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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."),
Expand Down
7 changes: 7 additions & 0 deletions tests/common/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -433,6 +433,7 @@ pub(crate) struct TestConfig {
pub node_entropy: NodeEntropy,
pub async_payments_role: Option<AsyncPaymentsRole>,
pub recovery_mode: bool,
pub wallet_birthday_height: Option<u32>,
}

impl Default for TestConfig {
Expand All@@ -445,13 +446,15 @@ 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,
store_type,
node_entropy,
async_payments_role,
recovery_mode,
wallet_birthday_height,
}
}
}
Expand DownExpand Up@@ -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());
Expand Down
42 changes: 42 additions & 0 deletions tests/integration_tests_rust.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions bindings/ldk_node.udl
Original file line numberDiff line numberDiff line change
Expand Up@@ -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]
Expand DownExpand Up@@ -194,6 +195,7 @@ enum NodeError {
"OnchainTxSigningFailed",
"TxSyncFailed",
"TxSyncTimeout",
"ChainAccessFailed",
"GossipUpdateFailed",
"GossipUpdateTimeout",
"LiquidityRequestFailed",
Expand Down
86 changes: 80 additions & 6 deletions src/builder.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -292,6 +292,7 @@ pub struct NodeBuilder {
runtime_handle: Option<tokio::runtime::Handle>,
pathfinding_scores_sync_config: Option<PathfindingScoresSyncConfig>,
recovery_mode: bool,
wallet_birthday_height: Option<u32>,
}

impl NodeBuilder {
Expand All@@ -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,
Expand All@@ -320,6 +322,7 @@ impl NodeBuilder {
async_payments_role: None,
pathfinding_scores_sync_config,
recovery_mode,
wallet_birthday_height,
}
}

Expand DownExpand Up@@ -625,6 +628,27 @@ impl NodeBuilder {
self
}

/// Sets the wallet birthday height for seed recovery on pruned nodes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current code makes birthday take precedence over set_wallet_recovery_mode (the if let Some(birthday_height) = … else if !recovery_mode { … } chain in build_with_store_internal). Worth a sentence here noting the precedence so callers who set both know what to expect.
"Use a conservative estimate" is direction-ambiguous. A height after the first tx would miss funds.
Maybe: "use a height earlier than your first transaction, lower is safer than higher."

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documented: the birthday height takes precedence over recovery mode.

///
/// 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<Node, BuildError> {
Expand DownExpand Up@@ -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,
Expand DownExpand Up@@ -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<NodeEntropy>) -> Result<Arc<Node>, BuildError> {
Expand DownExpand Up@@ -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<AsyncPaymentsRole>, recovery_mode: bool, seed_bytes: [u8; 64],
runtime: Arc<Runtime>, logger: Arc<Logger>, kv_store: Arc<DynStore>,
async_payments_role: Option<AsyncPaymentsRole>, recovery_mode: bool,
wallet_birthday_height: Option<u32>, seed_bytes: [u8; 64], runtime: Arc<Runtime>,
logger: Arc<Logger>, kv_store: Arc<DynStore>,
) -> Result<Node, BuildError> {
optionally_install_rustls_cryptoprovider();

Expand DownExpand Up@@ -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);
},
Comment on lines +1639 to +1651

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the user set a wallet birthday they almost certainly don't want to be silently checkpointed at chain tip on a transient hash-fetch failure, that defeats the whole point of the feature. A mobile recovery user would see 0 sats restored, assume their seed is wrong, and likely never look at the logs.
Two safer alternatives:

  1. Return BuildError::WalletSetupFailed so the caller can retry (or fall back at the application layer).
  2. Fall back to no checkpoint, i.e. the recovery_mode genesis-sync behavior, so the user still gets historical coverage at the cost of a full scan.
    I'd lean toward option 1 (surface the error). Falling back to tip is the worst outcome for the user. WDYT?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Now returns BuildError::WalletSetupFailed when a birthday is set but the block hash can't be fetched, rather than silently checkpointing at the tip.

}
} 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,
Expand All@@ -1594,6 +1667,7 @@ fn build_with_store_internal(
})?;
}
}
// else: recovery_mode without birthday syncs from genesis
wallet
},
};
Expand Down
32 changes: 32 additions & 0 deletions src/chain/electrum.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<bitcoin::BlockHash, ()> {
// 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<Wallet>,
) -> Result<(), Error> {
Expand DownExpand Up@@ -423,6 +447,14 @@ impl ElectrumRuntimeClient {
})
}

fn get_block_hash_by_height(&self, height: u32) -> Result<bitcoin::BlockHash, ()> {
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<Arc<dyn Confirm + Sync + Send>>,
) -> Result<(), Error> {
Expand Down
6 changes: 6 additions & 0 deletions src/chain/esplora.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,6 +76,12 @@ impl EsploraChainSource {
})
}

pub(super) async fn get_block_hash_by_height(
&self, height: u32,
) -> Result<bitcoin::BlockHash, ()> {
self.esplora_client.get_block_hash(height).await.map_err(|_| ())
}

pub(super) async fn sync_onchain_wallet(
&self, onchain_wallet: Arc<Wallet>,
) -> Result<(), Error> {
Expand Down
24 changes: 24 additions & 0 deletions src/chain/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every other method on ChainSource in this file returns Result<, Error> (e.g. start at L188, update_fee_rate_estimates at L446). Result<, ()> here drops useful context from all three backends: UtxoSource::get_block_hash_by_height returns BlockSourceResult, esplora_client.get_block_hash returns Result<, EsploraError>, and block_header_raw returns Result<, electrum_client::Error>. Surfacing those via crate::Error would be consistent and would let the call site reason about the failure mode (relevant to the comment on builder.rs:1419-1444).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Returns Error::ChainAccessFailed now, consistent with the other ChainSource methods, and mirrored into the UDL error enum.

&self, height: u32,
) -> Result<bitcoin::BlockHash, Error> {
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<Txid> {
self.registered_txids.lock().expect("lock").clone()
}
Expand Down
3 changes: 3 additions & 0 deletions src/error.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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."),
Expand Down
7 changes: 7 additions & 0 deletions tests/common/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -433,6 +433,7 @@ pub(crate) struct TestConfig {
pub node_entropy: NodeEntropy,
pub async_payments_role: Option<AsyncPaymentsRole>,
pub recovery_mode: bool,
pub wallet_birthday_height: Option<u32>,
}

impl Default for TestConfig {
Expand All@@ -445,13 +446,15 @@ 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,
store_type,
node_entropy,
async_payments_role,
recovery_mode,
wallet_birthday_height,
}
}
}
Expand DownExpand Up@@ -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());
Expand Down
42 changes: 42 additions & 0 deletions tests/integration_tests_rust.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions bindings/ldk_node.udl
Original file line numberDiff line numberDiff line change
Expand Up@@ -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]
Expand DownExpand Up@@ -194,6 +195,7 @@ enum NodeError {
"OnchainTxSigningFailed",
"TxSyncFailed",
"TxSyncTimeout",
"ChainAccessFailed",
"GossipUpdateFailed",
"GossipUpdateTimeout",
"LiquidityRequestFailed",
Expand Down
86 changes: 80 additions & 6 deletions src/builder.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -292,6 +292,7 @@ pub struct NodeBuilder {
runtime_handle: Option<tokio::runtime::Handle>,
pathfinding_scores_sync_config: Option<PathfindingScoresSyncConfig>,
recovery_mode: bool,
wallet_birthday_height: Option<u32>,
}

impl NodeBuilder {
Expand All@@ -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,
Expand All@@ -320,6 +322,7 @@ impl NodeBuilder {
async_payments_role: None,
pathfinding_scores_sync_config,
recovery_mode,
wallet_birthday_height,
}
}

Expand DownExpand Up@@ -625,6 +628,27 @@ impl NodeBuilder {
self
}

/// Sets the wallet birthday height for seed recovery on pruned nodes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current code makes birthday take precedence over set_wallet_recovery_mode (the if let Some(birthday_height) = … else if !recovery_mode { … } chain in build_with_store_internal). Worth a sentence here noting the precedence so callers who set both know what to expect.
"Use a conservative estimate" is direction-ambiguous. A height after the first tx would miss funds.
Maybe: "use a height earlier than your first transaction, lower is safer than higher."

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documented: the birthday height takes precedence over recovery mode.

///
/// 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<Node, BuildError> {
Expand DownExpand Up@@ -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,
Expand DownExpand Up@@ -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<NodeEntropy>) -> Result<Arc<Node>, BuildError> {
Expand DownExpand Up@@ -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<AsyncPaymentsRole>, recovery_mode: bool, seed_bytes: [u8; 64],
runtime: Arc<Runtime>, logger: Arc<Logger>, kv_store: Arc<DynStore>,
async_payments_role: Option<AsyncPaymentsRole>, recovery_mode: bool,
wallet_birthday_height: Option<u32>, seed_bytes: [u8; 64], runtime: Arc<Runtime>,
logger: Arc<Logger>, kv_store: Arc<DynStore>,
) -> Result<Node, BuildError> {
optionally_install_rustls_cryptoprovider();

Expand DownExpand Up@@ -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);
},
Comment on lines +1639 to +1651

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the user set a wallet birthday they almost certainly don't want to be silently checkpointed at chain tip on a transient hash-fetch failure, that defeats the whole point of the feature. A mobile recovery user would see 0 sats restored, assume their seed is wrong, and likely never look at the logs.
Two safer alternatives:

  1. Return BuildError::WalletSetupFailed so the caller can retry (or fall back at the application layer).
  2. Fall back to no checkpoint, i.e. the recovery_mode genesis-sync behavior, so the user still gets historical coverage at the cost of a full scan.
    I'd lean toward option 1 (surface the error). Falling back to tip is the worst outcome for the user. WDYT?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Now returns BuildError::WalletSetupFailed when a birthday is set but the block hash can't be fetched, rather than silently checkpointing at the tip.

}
} 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,
Expand All@@ -1594,6 +1667,7 @@ fn build_with_store_internal(
})?;
}
}
// else: recovery_mode without birthday syncs from genesis
wallet
},
};
Expand Down
32 changes: 32 additions & 0 deletions src/chain/electrum.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<bitcoin::BlockHash, ()> {
// 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<Wallet>,
) -> Result<(), Error> {
Expand DownExpand Up@@ -423,6 +447,14 @@ impl ElectrumRuntimeClient {
})
}

fn get_block_hash_by_height(&self, height: u32) -> Result<bitcoin::BlockHash, ()> {
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<Arc<dyn Confirm + Sync + Send>>,
) -> Result<(), Error> {
Expand Down
6 changes: 6 additions & 0 deletions src/chain/esplora.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,6 +76,12 @@ impl EsploraChainSource {
})
}

pub(super) async fn get_block_hash_by_height(
&self, height: u32,
) -> Result<bitcoin::BlockHash, ()> {
self.esplora_client.get_block_hash(height).await.map_err(|_| ())
}

pub(super) async fn sync_onchain_wallet(
&self, onchain_wallet: Arc<Wallet>,
) -> Result<(), Error> {
Expand Down
24 changes: 24 additions & 0 deletions src/chain/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every other method on ChainSource in this file returns Result<, Error> (e.g. start at L188, update_fee_rate_estimates at L446). Result<, ()> here drops useful context from all three backends: UtxoSource::get_block_hash_by_height returns BlockSourceResult, esplora_client.get_block_hash returns Result<, EsploraError>, and block_header_raw returns Result<, electrum_client::Error>. Surfacing those via crate::Error would be consistent and would let the call site reason about the failure mode (relevant to the comment on builder.rs:1419-1444).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Returns Error::ChainAccessFailed now, consistent with the other ChainSource methods, and mirrored into the UDL error enum.

&self, height: u32,
) -> Result<bitcoin::BlockHash, Error> {
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<Txid> {
self.registered_txids.lock().expect("lock").clone()
}
Expand Down
3 changes: 3 additions & 0 deletions src/error.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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."),
Expand Down
7 changes: 7 additions & 0 deletions tests/common/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -433,6 +433,7 @@ pub(crate) struct TestConfig {
pub node_entropy: NodeEntropy,
pub async_payments_role: Option<AsyncPaymentsRole>,
pub recovery_mode: bool,
pub wallet_birthday_height: Option<u32>,
}

impl Default for TestConfig {
Expand All@@ -445,13 +446,15 @@ 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,
store_type,
node_entropy,
async_payments_role,
recovery_mode,
wallet_birthday_height,
}
}
}
Expand DownExpand Up@@ -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());
Expand Down
42 changes: 42 additions & 0 deletions tests/integration_tests_rust.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions bindings/ldk_node.udl
Original file line numberDiff line numberDiff line change
Expand Up@@ -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]
Expand DownExpand Up@@ -194,6 +195,7 @@ enum NodeError {
"OnchainTxSigningFailed",
"TxSyncFailed",
"TxSyncTimeout",
"ChainAccessFailed",
"GossipUpdateFailed",
"GossipUpdateTimeout",
"LiquidityRequestFailed",
Expand Down
86 changes: 80 additions & 6 deletions src/builder.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -292,6 +292,7 @@ pub struct NodeBuilder {
runtime_handle: Option<tokio::runtime::Handle>,
pathfinding_scores_sync_config: Option<PathfindingScoresSyncConfig>,
recovery_mode: bool,
wallet_birthday_height: Option<u32>,
}

impl NodeBuilder {
Expand All@@ -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,
Expand All@@ -320,6 +322,7 @@ impl NodeBuilder {
async_payments_role: None,
pathfinding_scores_sync_config,
recovery_mode,
wallet_birthday_height,
}
}

Expand DownExpand Up@@ -625,6 +628,27 @@ impl NodeBuilder {
self
}

/// Sets the wallet birthday height for seed recovery on pruned nodes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current code makes birthday take precedence over set_wallet_recovery_mode (the if let Some(birthday_height) = … else if !recovery_mode { … } chain in build_with_store_internal). Worth a sentence here noting the precedence so callers who set both know what to expect.
"Use a conservative estimate" is direction-ambiguous. A height after the first tx would miss funds.
Maybe: "use a height earlier than your first transaction, lower is safer than higher."

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documented: the birthday height takes precedence over recovery mode.

///
/// 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<Node, BuildError> {
Expand DownExpand Up@@ -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,
Expand DownExpand Up@@ -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<NodeEntropy>) -> Result<Arc<Node>, BuildError> {
Expand DownExpand Up@@ -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<AsyncPaymentsRole>, recovery_mode: bool, seed_bytes: [u8; 64],
runtime: Arc<Runtime>, logger: Arc<Logger>, kv_store: Arc<DynStore>,
async_payments_role: Option<AsyncPaymentsRole>, recovery_mode: bool,
wallet_birthday_height: Option<u32>, seed_bytes: [u8; 64], runtime: Arc<Runtime>,
logger: Arc<Logger>, kv_store: Arc<DynStore>,
) -> Result<Node, BuildError> {
optionally_install_rustls_cryptoprovider();

Expand DownExpand Up@@ -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);
},
Comment on lines +1639 to +1651

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the user set a wallet birthday they almost certainly don't want to be silently checkpointed at chain tip on a transient hash-fetch failure, that defeats the whole point of the feature. A mobile recovery user would see 0 sats restored, assume their seed is wrong, and likely never look at the logs.
Two safer alternatives:

  1. Return BuildError::WalletSetupFailed so the caller can retry (or fall back at the application layer).
  2. Fall back to no checkpoint, i.e. the recovery_mode genesis-sync behavior, so the user still gets historical coverage at the cost of a full scan.
    I'd lean toward option 1 (surface the error). Falling back to tip is the worst outcome for the user. WDYT?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Now returns BuildError::WalletSetupFailed when a birthday is set but the block hash can't be fetched, rather than silently checkpointing at the tip.

}
} 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,
Expand All@@ -1594,6 +1667,7 @@ fn build_with_store_internal(
})?;
}
}
// else: recovery_mode without birthday syncs from genesis
wallet
},
};
Expand Down
32 changes: 32 additions & 0 deletions src/chain/electrum.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<bitcoin::BlockHash, ()> {
// 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<Wallet>,
) -> Result<(), Error> {
Expand DownExpand Up@@ -423,6 +447,14 @@ impl ElectrumRuntimeClient {
})
}

fn get_block_hash_by_height(&self, height: u32) -> Result<bitcoin::BlockHash, ()> {
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<Arc<dyn Confirm + Sync + Send>>,
) -> Result<(), Error> {
Expand Down
6 changes: 6 additions & 0 deletions src/chain/esplora.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,6 +76,12 @@ impl EsploraChainSource {
})
}

pub(super) async fn get_block_hash_by_height(
&self, height: u32,
) -> Result<bitcoin::BlockHash, ()> {
self.esplora_client.get_block_hash(height).await.map_err(|_| ())
}

pub(super) async fn sync_onchain_wallet(
&self, onchain_wallet: Arc<Wallet>,
) -> Result<(), Error> {
Expand Down
24 changes: 24 additions & 0 deletions src/chain/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every other method on ChainSource in this file returns Result<, Error> (e.g. start at L188, update_fee_rate_estimates at L446). Result<, ()> here drops useful context from all three backends: UtxoSource::get_block_hash_by_height returns BlockSourceResult, esplora_client.get_block_hash returns Result<, EsploraError>, and block_header_raw returns Result<, electrum_client::Error>. Surfacing those via crate::Error would be consistent and would let the call site reason about the failure mode (relevant to the comment on builder.rs:1419-1444).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Returns Error::ChainAccessFailed now, consistent with the other ChainSource methods, and mirrored into the UDL error enum.

&self, height: u32,
) -> Result<bitcoin::BlockHash, Error> {
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<Txid> {
self.registered_txids.lock().expect("lock").clone()
}
Expand Down
3 changes: 3 additions & 0 deletions src/error.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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."),
Expand Down
7 changes: 7 additions & 0 deletions tests/common/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -433,6 +433,7 @@ pub(crate) struct TestConfig {
pub node_entropy: NodeEntropy,
pub async_payments_role: Option<AsyncPaymentsRole>,
pub recovery_mode: bool,
pub wallet_birthday_height: Option<u32>,
}

impl Default for TestConfig {
Expand All@@ -445,13 +446,15 @@ 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,
store_type,
node_entropy,
async_payments_role,
recovery_mode,
wallet_birthday_height,
}
}
}
Expand DownExpand Up@@ -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());
Expand Down
42 changes: 42 additions & 0 deletions tests/integration_tests_rust.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions bindings/ldk_node.udl
Original file line numberDiff line numberDiff line change
Expand Up@@ -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]
Expand DownExpand Up@@ -194,6 +195,7 @@ enum NodeError {
"OnchainTxSigningFailed",
"TxSyncFailed",
"TxSyncTimeout",
"ChainAccessFailed",
"GossipUpdateFailed",
"GossipUpdateTimeout",
"LiquidityRequestFailed",
Expand Down
86 changes: 80 additions & 6 deletions src/builder.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -292,6 +292,7 @@ pub struct NodeBuilder {
runtime_handle: Option<tokio::runtime::Handle>,
pathfinding_scores_sync_config: Option<PathfindingScoresSyncConfig>,
recovery_mode: bool,
wallet_birthday_height: Option<u32>,
}

impl NodeBuilder {
Expand All@@ -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,
Expand All@@ -320,6 +322,7 @@ impl NodeBuilder {
async_payments_role: None,
pathfinding_scores_sync_config,
recovery_mode,
wallet_birthday_height,
}
}

Expand DownExpand Up@@ -625,6 +628,27 @@ impl NodeBuilder {
self
}

/// Sets the wallet birthday height for seed recovery on pruned nodes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current code makes birthday take precedence over set_wallet_recovery_mode (the if let Some(birthday_height) = … else if !recovery_mode { … } chain in build_with_store_internal). Worth a sentence here noting the precedence so callers who set both know what to expect.
"Use a conservative estimate" is direction-ambiguous. A height after the first tx would miss funds.
Maybe: "use a height earlier than your first transaction, lower is safer than higher."

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documented: the birthday height takes precedence over recovery mode.

///
/// 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<Node, BuildError> {
Expand DownExpand Up@@ -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,
Expand DownExpand Up@@ -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<NodeEntropy>) -> Result<Arc<Node>, BuildError> {
Expand DownExpand Up@@ -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<AsyncPaymentsRole>, recovery_mode: bool, seed_bytes: [u8; 64],
runtime: Arc<Runtime>, logger: Arc<Logger>, kv_store: Arc<DynStore>,
async_payments_role: Option<AsyncPaymentsRole>, recovery_mode: bool,
wallet_birthday_height: Option<u32>, seed_bytes: [u8; 64], runtime: Arc<Runtime>,
logger: Arc<Logger>, kv_store: Arc<DynStore>,
) -> Result<Node, BuildError> {
optionally_install_rustls_cryptoprovider();

Expand DownExpand Up@@ -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);
},
Comment on lines +1639 to +1651

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the user set a wallet birthday they almost certainly don't want to be silently checkpointed at chain tip on a transient hash-fetch failure, that defeats the whole point of the feature. A mobile recovery user would see 0 sats restored, assume their seed is wrong, and likely never look at the logs.
Two safer alternatives:

  1. Return BuildError::WalletSetupFailed so the caller can retry (or fall back at the application layer).
  2. Fall back to no checkpoint, i.e. the recovery_mode genesis-sync behavior, so the user still gets historical coverage at the cost of a full scan.
    I'd lean toward option 1 (surface the error). Falling back to tip is the worst outcome for the user. WDYT?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Now returns BuildError::WalletSetupFailed when a birthday is set but the block hash can't be fetched, rather than silently checkpointing at the tip.

}
} 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,
Expand All@@ -1594,6 +1667,7 @@ fn build_with_store_internal(
})?;
}
}
// else: recovery_mode without birthday syncs from genesis
wallet
},
};
Expand Down
32 changes: 32 additions & 0 deletions src/chain/electrum.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<bitcoin::BlockHash, ()> {
// 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<Wallet>,
) -> Result<(), Error> {
Expand DownExpand Up@@ -423,6 +447,14 @@ impl ElectrumRuntimeClient {
})
}

fn get_block_hash_by_height(&self, height: u32) -> Result<bitcoin::BlockHash, ()> {
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<Arc<dyn Confirm + Sync + Send>>,
) -> Result<(), Error> {
Expand Down
6 changes: 6 additions & 0 deletions src/chain/esplora.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,6 +76,12 @@ impl EsploraChainSource {
})
}

pub(super) async fn get_block_hash_by_height(
&self, height: u32,
) -> Result<bitcoin::BlockHash, ()> {
self.esplora_client.get_block_hash(height).await.map_err(|_| ())
}

pub(super) async fn sync_onchain_wallet(
&self, onchain_wallet: Arc<Wallet>,
) -> Result<(), Error> {
Expand Down
24 changes: 24 additions & 0 deletions src/chain/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every other method on ChainSource in this file returns Result<, Error> (e.g. start at L188, update_fee_rate_estimates at L446). Result<, ()> here drops useful context from all three backends: UtxoSource::get_block_hash_by_height returns BlockSourceResult, esplora_client.get_block_hash returns Result<, EsploraError>, and block_header_raw returns Result<, electrum_client::Error>. Surfacing those via crate::Error would be consistent and would let the call site reason about the failure mode (relevant to the comment on builder.rs:1419-1444).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Returns Error::ChainAccessFailed now, consistent with the other ChainSource methods, and mirrored into the UDL error enum.

&self, height: u32,
) -> Result<bitcoin::BlockHash, Error> {
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<Txid> {
self.registered_txids.lock().expect("lock").clone()
}
Expand Down
3 changes: 3 additions & 0 deletions src/error.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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."),
Expand Down
7 changes: 7 additions & 0 deletions tests/common/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -433,6 +433,7 @@ pub(crate) struct TestConfig {
pub node_entropy: NodeEntropy,
pub async_payments_role: Option<AsyncPaymentsRole>,
pub recovery_mode: bool,
pub wallet_birthday_height: Option<u32>,
}

impl Default for TestConfig {
Expand All@@ -445,13 +446,15 @@ 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,
store_type,
node_entropy,
async_payments_role,
recovery_mode,
wallet_birthday_height,
}
}
}
Expand DownExpand Up@@ -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());
Expand Down
42 changes: 42 additions & 0 deletions tests/integration_tests_rust.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions bindings/ldk_node.udl
Original file line numberDiff line numberDiff line change
Expand Up@@ -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]
Expand DownExpand Up@@ -194,6 +195,7 @@ enum NodeError {
"OnchainTxSigningFailed",
"TxSyncFailed",
"TxSyncTimeout",
"ChainAccessFailed",
"GossipUpdateFailed",
"GossipUpdateTimeout",
"LiquidityRequestFailed",
Expand Down
86 changes: 80 additions & 6 deletions src/builder.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -292,6 +292,7 @@ pub struct NodeBuilder {
runtime_handle: Option<tokio::runtime::Handle>,
pathfinding_scores_sync_config: Option<PathfindingScoresSyncConfig>,
recovery_mode: bool,
wallet_birthday_height: Option<u32>,
}

impl NodeBuilder {
Expand All@@ -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,
Expand All@@ -320,6 +322,7 @@ impl NodeBuilder {
async_payments_role: None,
pathfinding_scores_sync_config,
recovery_mode,
wallet_birthday_height,
}
}

Expand DownExpand Up@@ -625,6 +628,27 @@ impl NodeBuilder {
self
}

/// Sets the wallet birthday height for seed recovery on pruned nodes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current code makes birthday take precedence over set_wallet_recovery_mode (the if let Some(birthday_height) = … else if !recovery_mode { … } chain in build_with_store_internal). Worth a sentence here noting the precedence so callers who set both know what to expect.
"Use a conservative estimate" is direction-ambiguous. A height after the first tx would miss funds.
Maybe: "use a height earlier than your first transaction, lower is safer than higher."

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documented: the birthday height takes precedence over recovery mode.

///
/// 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<Node, BuildError> {
Expand DownExpand Up@@ -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,
Expand DownExpand Up@@ -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<NodeEntropy>) -> Result<Arc<Node>, BuildError> {
Expand DownExpand Up@@ -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<AsyncPaymentsRole>, recovery_mode: bool, seed_bytes: [u8; 64],
runtime: Arc<Runtime>, logger: Arc<Logger>, kv_store: Arc<DynStore>,
async_payments_role: Option<AsyncPaymentsRole>, recovery_mode: bool,
wallet_birthday_height: Option<u32>, seed_bytes: [u8; 64], runtime: Arc<Runtime>,
logger: Arc<Logger>, kv_store: Arc<DynStore>,
) -> Result<Node, BuildError> {
optionally_install_rustls_cryptoprovider();

Expand DownExpand Up@@ -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);
},
Comment on lines +1639 to +1651

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the user set a wallet birthday they almost certainly don't want to be silently checkpointed at chain tip on a transient hash-fetch failure, that defeats the whole point of the feature. A mobile recovery user would see 0 sats restored, assume their seed is wrong, and likely never look at the logs.
Two safer alternatives:

  1. Return BuildError::WalletSetupFailed so the caller can retry (or fall back at the application layer).
  2. Fall back to no checkpoint, i.e. the recovery_mode genesis-sync behavior, so the user still gets historical coverage at the cost of a full scan.
    I'd lean toward option 1 (surface the error). Falling back to tip is the worst outcome for the user. WDYT?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Now returns BuildError::WalletSetupFailed when a birthday is set but the block hash can't be fetched, rather than silently checkpointing at the tip.

}
} 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,
Expand All@@ -1594,6 +1667,7 @@ fn build_with_store_internal(
})?;
}
}
// else: recovery_mode without birthday syncs from genesis
wallet
},
};
Expand Down
32 changes: 32 additions & 0 deletions src/chain/electrum.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<bitcoin::BlockHash, ()> {
// 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<Wallet>,
) -> Result<(), Error> {
Expand DownExpand Up@@ -423,6 +447,14 @@ impl ElectrumRuntimeClient {
})
}

fn get_block_hash_by_height(&self, height: u32) -> Result<bitcoin::BlockHash, ()> {
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<Arc<dyn Confirm + Sync + Send>>,
) -> Result<(), Error> {
Expand Down
6 changes: 6 additions & 0 deletions src/chain/esplora.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,6 +76,12 @@ impl EsploraChainSource {
})
}

pub(super) async fn get_block_hash_by_height(
&self, height: u32,
) -> Result<bitcoin::BlockHash, ()> {
self.esplora_client.get_block_hash(height).await.map_err(|_| ())
}

pub(super) async fn sync_onchain_wallet(
&self, onchain_wallet: Arc<Wallet>,
) -> Result<(), Error> {
Expand Down
24 changes: 24 additions & 0 deletions src/chain/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every other method on ChainSource in this file returns Result<, Error> (e.g. start at L188, update_fee_rate_estimates at L446). Result<, ()> here drops useful context from all three backends: UtxoSource::get_block_hash_by_height returns BlockSourceResult, esplora_client.get_block_hash returns Result<, EsploraError>, and block_header_raw returns Result<, electrum_client::Error>. Surfacing those via crate::Error would be consistent and would let the call site reason about the failure mode (relevant to the comment on builder.rs:1419-1444).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Returns Error::ChainAccessFailed now, consistent with the other ChainSource methods, and mirrored into the UDL error enum.

&self, height: u32,
) -> Result<bitcoin::BlockHash, Error> {
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<Txid> {
self.registered_txids.lock().expect("lock").clone()
}
Expand Down
3 changes: 3 additions & 0 deletions src/error.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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."),
Expand Down
7 changes: 7 additions & 0 deletions tests/common/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -433,6 +433,7 @@ pub(crate) struct TestConfig {
pub node_entropy: NodeEntropy,
pub async_payments_role: Option<AsyncPaymentsRole>,
pub recovery_mode: bool,
pub wallet_birthday_height: Option<u32>,
}

impl Default for TestConfig {
Expand All@@ -445,13 +446,15 @@ 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,
store_type,
node_entropy,
async_payments_role,
recovery_mode,
wallet_birthday_height,
}
}
}
Expand DownExpand Up@@ -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());
Expand Down
42 changes: 42 additions & 0 deletions tests/integration_tests_rust.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions bindings/ldk_node.udl
Original file line numberDiff line numberDiff line change
Expand Up@@ -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]
Expand DownExpand Up@@ -194,6 +195,7 @@ enum NodeError {
"OnchainTxSigningFailed",
"TxSyncFailed",
"TxSyncTimeout",
"ChainAccessFailed",
"GossipUpdateFailed",
"GossipUpdateTimeout",
"LiquidityRequestFailed",
Expand Down
86 changes: 80 additions & 6 deletions src/builder.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -292,6 +292,7 @@ pub struct NodeBuilder {
runtime_handle: Option<tokio::runtime::Handle>,
pathfinding_scores_sync_config: Option<PathfindingScoresSyncConfig>,
recovery_mode: bool,
wallet_birthday_height: Option<u32>,
}

impl NodeBuilder {
Expand All@@ -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,
Expand All@@ -320,6 +322,7 @@ impl NodeBuilder {
async_payments_role: None,
pathfinding_scores_sync_config,
recovery_mode,
wallet_birthday_height,
}
}

Expand DownExpand Up@@ -625,6 +628,27 @@ impl NodeBuilder {
self
}

/// Sets the wallet birthday height for seed recovery on pruned nodes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current code makes birthday take precedence over set_wallet_recovery_mode (the if let Some(birthday_height) = … else if !recovery_mode { … } chain in build_with_store_internal). Worth a sentence here noting the precedence so callers who set both know what to expect.
"Use a conservative estimate" is direction-ambiguous. A height after the first tx would miss funds.
Maybe: "use a height earlier than your first transaction, lower is safer than higher."

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documented: the birthday height takes precedence over recovery mode.

///
/// 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<Node, BuildError> {
Expand DownExpand Up@@ -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,
Expand DownExpand Up@@ -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<NodeEntropy>) -> Result<Arc<Node>, BuildError> {
Expand DownExpand Up@@ -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<AsyncPaymentsRole>, recovery_mode: bool, seed_bytes: [u8; 64],
runtime: Arc<Runtime>, logger: Arc<Logger>, kv_store: Arc<DynStore>,
async_payments_role: Option<AsyncPaymentsRole>, recovery_mode: bool,
wallet_birthday_height: Option<u32>, seed_bytes: [u8; 64], runtime: Arc<Runtime>,
logger: Arc<Logger>, kv_store: Arc<DynStore>,
) -> Result<Node, BuildError> {
optionally_install_rustls_cryptoprovider();

Expand DownExpand Up@@ -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);
},
Comment on lines +1639 to +1651

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the user set a wallet birthday they almost certainly don't want to be silently checkpointed at chain tip on a transient hash-fetch failure, that defeats the whole point of the feature. A mobile recovery user would see 0 sats restored, assume their seed is wrong, and likely never look at the logs.
Two safer alternatives:

  1. Return BuildError::WalletSetupFailed so the caller can retry (or fall back at the application layer).
  2. Fall back to no checkpoint, i.e. the recovery_mode genesis-sync behavior, so the user still gets historical coverage at the cost of a full scan.
    I'd lean toward option 1 (surface the error). Falling back to tip is the worst outcome for the user. WDYT?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Now returns BuildError::WalletSetupFailed when a birthday is set but the block hash can't be fetched, rather than silently checkpointing at the tip.

}
} 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,
Expand All@@ -1594,6 +1667,7 @@ fn build_with_store_internal(
})?;
}
}
// else: recovery_mode without birthday syncs from genesis
wallet
},
};
Expand Down
32 changes: 32 additions & 0 deletions src/chain/electrum.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<bitcoin::BlockHash, ()> {
// 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<Wallet>,
) -> Result<(), Error> {
Expand DownExpand Up@@ -423,6 +447,14 @@ impl ElectrumRuntimeClient {
})
}

fn get_block_hash_by_height(&self, height: u32) -> Result<bitcoin::BlockHash, ()> {
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<Arc<dyn Confirm + Sync + Send>>,
) -> Result<(), Error> {
Expand Down
6 changes: 6 additions & 0 deletions src/chain/esplora.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,6 +76,12 @@ impl EsploraChainSource {
})
}

pub(super) async fn get_block_hash_by_height(
&self, height: u32,
) -> Result<bitcoin::BlockHash, ()> {
self.esplora_client.get_block_hash(height).await.map_err(|_| ())
}

pub(super) async fn sync_onchain_wallet(
&self, onchain_wallet: Arc<Wallet>,
) -> Result<(), Error> {
Expand Down
24 changes: 24 additions & 0 deletions src/chain/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every other method on ChainSource in this file returns Result<, Error> (e.g. start at L188, update_fee_rate_estimates at L446). Result<, ()> here drops useful context from all three backends: UtxoSource::get_block_hash_by_height returns BlockSourceResult, esplora_client.get_block_hash returns Result<, EsploraError>, and block_header_raw returns Result<, electrum_client::Error>. Surfacing those via crate::Error would be consistent and would let the call site reason about the failure mode (relevant to the comment on builder.rs:1419-1444).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Returns Error::ChainAccessFailed now, consistent with the other ChainSource methods, and mirrored into the UDL error enum.

&self, height: u32,
) -> Result<bitcoin::BlockHash, Error> {
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<Txid> {
self.registered_txids.lock().expect("lock").clone()
}
Expand Down
3 changes: 3 additions & 0 deletions src/error.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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."),
Expand Down
7 changes: 7 additions & 0 deletions tests/common/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -433,6 +433,7 @@ pub(crate) struct TestConfig {
pub node_entropy: NodeEntropy,
pub async_payments_role: Option<AsyncPaymentsRole>,
pub recovery_mode: bool,
pub wallet_birthday_height: Option<u32>,
}

impl Default for TestConfig {
Expand All@@ -445,13 +446,15 @@ 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,
store_type,
node_entropy,
async_payments_role,
recovery_mode,
wallet_birthday_height,
}
}
}
Expand DownExpand Up@@ -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());
Expand Down
42 changes: 42 additions & 0 deletions tests/integration_tests_rust.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand Down
Loading