Skip to content
Open
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 number Diff line number Diff 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 Down Expand 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 number Diff line number Diff 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 Down Expand 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 Down Expand 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 Down Expand 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 Down Expand 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 Down Expand 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 Down Expand 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 number Diff line number Diff 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 Down Expand 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 number Diff line number Diff 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 number Diff line number Diff 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 Down Expand 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 number Diff line number Diff 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 Down Expand 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 number Diff line number Diff 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 Down Expand 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 number Diff line number Diff 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