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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,23 @@
- Users of the VSS storage backend must upgrade their VSS server to at least version
`v0.1.0-alpha.0` before upgrading LDK Node.

## Feature and API updates
- The Bitcoin Core RPC and REST chain-source builder methods now accept an optional
`wallet_rescan_from_height` argument. Passing a height lets fresh wallets rescan from a known
birthday block instead of checkpointing at the current tip, which is useful when restoring a
wallet on a pruned node where the full history is unavailable but the wallet birthday height is
known. Existing wallets are not rewound, and future heights fail the build. Passing `Some(0)`
rescans from genesis; passing `None` keeps the default current-tip checkpoint behavior. (#884)
- `EsploraSyncConfig` and `ElectrumSyncConfig` now support `force_wallet_full_scan`. When set,
the on-chain wallet keeps using BDK `full_scan` instead of incremental sync until a full scan
succeeds, allowing restored wallets to rediscover funds sent to previously-unknown addresses.

## Bug Fixes and Improvements
- Building a fresh node against a Bitcoin Core RPC or REST chain source that fails to return the
current chain tip now aborts with a new `BuildError::ChainTipFetchFailed` variant instead of
silently pinning the wallet birthday to genesis, which would have forced a full-history rescan
once the chain source became reachable again. (#884)

# 0.7.0 - Dec. 3, 2025
This seventh minor release introduces numerous new features, bug fixes, and API improvements. In particular, it adds support for channel Splicing, Async Payments, as well as sourcing chain data from a Bitcoin Core REST backend.

Expand Down
5 changes: 2 additions & 3 deletions bindings/ldk_node.udl
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ interface Builder {
constructor(Config config);
void set_chain_source_esplora(string server_url, EsploraSyncConfig? config);
void set_chain_source_electrum(string server_url, ElectrumSyncConfig? config);
void set_chain_source_bitcoind_rpc(string rpc_host, u16 rpc_port, string rpc_user, string rpc_password);
void set_chain_source_bitcoind_rest(string rest_host, u16 rest_port, string rpc_host, u16 rpc_port, string rpc_user, string rpc_password);
void set_chain_source_bitcoind_rpc(string rpc_host, u16 rpc_port, string rpc_user, string rpc_password, u32? wallet_rescan_from_height);
void set_chain_source_bitcoind_rest(string rest_host, u16 rest_port, string rpc_host, u16 rpc_port, string rpc_user, string rpc_password, u32? wallet_rescan_from_height);
void set_gossip_source_p2p();
void set_gossip_source_rgs(string rgs_server_url);
void set_pathfinding_scores_source(string url);
Expand All @@ -59,7 +59,6 @@ interface Builder {
void set_node_alias(string node_alias);
[Throws=BuildError]
void set_async_payments_role(AsyncPaymentsRole? role);
void set_wallet_recovery_mode();
[Throws=BuildError]
Node build(NodeEntropy node_entropy);
[Throws=BuildError]
Expand Down
180 changes: 137 additions & 43 deletions src/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ enum ChainDataSourceConfig {
rpc_user: String,
rpc_password: String,
rest_client_config: Option<BitcoindRestClientConfig>,
wallet_rescan_from_height: Option<u32>,
},
}

Expand Down Expand Up @@ -196,6 +197,15 @@ pub enum BuildError {
AsyncPaymentsConfigMismatch,
/// An attempt to setup a DNS Resolver failed.
DNSResolverSetupFailed,
/// We failed to determine the current chain tip on first startup.
///
/// Returned when a fresh node is built against a Bitcoin Core RPC or REST chain source that
/// is unreachable or misconfigured, so we cannot learn the tip height/hash to use as the
/// wallet birthday. Falling back to genesis would silently force a full-history rescan on
/// the next successful startup, so we abort instead.
ChainTipFetchFailed,
/// The configured wallet rescan height is above the current chain tip.
WalletRescanHeightTooHigh,
}

impl fmt::Display for BuildError {
Expand Down Expand Up @@ -233,6 +243,15 @@ impl fmt::Display for BuildError {
Self::DNSResolverSetupFailed => {
write!(f, "An attempt to setup a DNS resolver has failed.")
},
Self::ChainTipFetchFailed => {
write!(
f,
"Failed to determine the current chain tip on first startup. Verify the chain data source is reachable and correctly configured."
)
},
Self::WalletRescanHeightTooHigh => {
write!(f, "Wallet rescan height is above the current chain tip.")
},
}
}
}
Expand Down Expand Up @@ -287,7 +306,6 @@ pub struct NodeBuilder {
async_payments_role: Option<AsyncPaymentsRole>,
runtime_handle: Option<tokio::runtime::Handle>,
pathfinding_scores_sync_config: Option<PathfindingScoresSyncConfig>,
recovery_mode: bool,
}

impl NodeBuilder {
Expand All @@ -305,7 +323,6 @@ impl NodeBuilder {
let log_writer_config = None;
let runtime_handle = None;
let pathfinding_scores_sync_config = None;
let recovery_mode = false;
Self {
config,
chain_data_source_config,
Expand All @@ -315,7 +332,6 @@ impl NodeBuilder {
runtime_handle,
async_payments_role: None,
pathfinding_scores_sync_config,
recovery_mode,
}
}

Expand Down Expand Up @@ -380,15 +396,21 @@ impl NodeBuilder {
/// ## Parameters:
/// * `rpc_host`, `rpc_port`, `rpc_user`, `rpc_password` - Required parameters for the Bitcoin Core RPC
/// connection.
/// * `wallet_rescan_from_height` - Optional wallet birthday height to rescan from on first
/// startup, before wallet state exists. Existing wallets are not rewound. The height must
/// be at or below the current tip. Passing `Some(0)` rescans from genesis; passing `None`
/// checkpoints at the current tip.
pub fn set_chain_source_bitcoind_rpc(
&mut self, rpc_host: String, rpc_port: u16, rpc_user: String, rpc_password: String,
wallet_rescan_from_height: Option<u32>,
) -> &mut Self {
self.chain_data_source_config = Some(ChainDataSourceConfig::Bitcoind {
rpc_host,
rpc_port,
rpc_user,
rpc_password,
rest_client_config: None,
wallet_rescan_from_height,
});
self
}
Expand All @@ -402,16 +424,21 @@ impl NodeBuilder {
/// * `rest_host`, `rest_port` - Required parameters for the Bitcoin Core REST connection.
/// * `rpc_host`, `rpc_port`, `rpc_user`, `rpc_password` - Required parameters for the Bitcoin Core RPC
/// connection
/// * `wallet_rescan_from_height` - Optional wallet birthday height to rescan from on first
/// startup, before wallet state exists. Existing wallets are not rewound. The height must
/// be at or below the current tip. Passing `Some(0)` rescans from genesis; passing `None`
/// checkpoints at the current tip.
pub fn set_chain_source_bitcoind_rest(
&mut self, rest_host: String, rest_port: u16, rpc_host: String, rpc_port: u16,
rpc_user: String, rpc_password: String,
rpc_user: String, rpc_password: String, wallet_rescan_from_height: Option<u32>,
) -> &mut Self {
self.chain_data_source_config = Some(ChainDataSourceConfig::Bitcoind {
rpc_host,
rpc_port,
rpc_user,
rpc_password,
rest_client_config: Some(BitcoindRestClientConfig { rest_host, rest_port }),
wallet_rescan_from_height,
});

self
Expand Down Expand Up @@ -602,16 +629,6 @@ impl NodeBuilder {
Ok(self)
}

/// Configures the [`Node`] to resync chain data from genesis on first startup, recovering any
/// historical wallet funds.
///
/// This should only be set on first startup when importing an older wallet from a previously
/// used [`NodeEntropy`].
pub fn set_wallet_recovery_mode(&mut self) -> &mut Self {
self.recovery_mode = true;
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 @@ -852,7 +869,6 @@ impl NodeBuilder {
self.liquidity_source_config.as_ref(),
self.pathfinding_scores_sync_config.as_ref(),
self.async_payments_role,
self.recovery_mode,
seed_bytes,
runtime,
logger,
Expand Down Expand Up @@ -966,14 +982,20 @@ impl ArcedNodeBuilder {
/// ## Parameters:
/// * `rpc_host`, `rpc_port`, `rpc_user`, `rpc_password` - Required parameters for the Bitcoin Core RPC
/// connection.
/// * `wallet_rescan_from_height` - Optional wallet birthday height to rescan from on first
/// startup, before wallet state exists. Existing wallets are not rewound. The height must
/// be at or below the current tip. Passing `Some(0)` rescans from genesis; passing `None`
/// checkpoints at the current tip.
pub fn set_chain_source_bitcoind_rpc(
&self, rpc_host: String, rpc_port: u16, rpc_user: String, rpc_password: String,
wallet_rescan_from_height: Option<u32>,
) {
self.inner.write().expect("lock").set_chain_source_bitcoind_rpc(
rpc_host,
rpc_port,
rpc_user,
rpc_password,
wallet_rescan_from_height,
);
}

Expand All @@ -986,9 +1008,13 @@ impl ArcedNodeBuilder {
/// * `rest_host`, `rest_port` - Required parameters for the Bitcoin Core REST connection.
/// * `rpc_host`, `rpc_port`, `rpc_user`, `rpc_password` - Required parameters for the Bitcoin Core RPC
/// connection
/// * `wallet_rescan_from_height` - Optional wallet birthday height to rescan from on first
/// startup, before wallet state exists. Existing wallets are not rewound. The height must
/// be at or below the current tip. Passing `Some(0)` rescans from genesis; passing `None`
/// checkpoints at the current tip.
pub fn set_chain_source_bitcoind_rest(
&self, rest_host: String, rest_port: u16, rpc_host: String, rpc_port: u16,
rpc_user: String, rpc_password: String,
rpc_user: String, rpc_password: String, wallet_rescan_from_height: Option<u32>,
) {
self.inner.write().expect("lock").set_chain_source_bitcoind_rest(
rest_host,
Expand All @@ -997,6 +1023,7 @@ impl ArcedNodeBuilder {
rpc_port,
rpc_user,
rpc_password,
wallet_rescan_from_height,
);
}

Expand Down Expand Up @@ -1139,15 +1166,6 @@ impl ArcedNodeBuilder {
self.inner.write().expect("lock").set_async_payments_role(role).map(|_| ())
}

/// Configures the [`Node`] to resync chain data from genesis on first startup, recovering any
/// historical wallet funds.
///
/// This should only be set on first startup when importing an older wallet from a previously
/// used [`NodeEntropy`].
pub fn set_wallet_recovery_mode(&self) {
self.inner.write().expect("lock").set_wallet_recovery_mode();
}

/// 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 @@ -1343,8 +1361,8 @@ fn build_with_store_internal(
gossip_source_config: Option<&GossipSourceConfig>,
liquidity_source_config: Option<&LiquiditySourceConfig>,
pathfinding_scores_sync_config: Option<&PathfindingScoresSyncConfig>,
async_payments_role: Option<AsyncPaymentsRole>, recovery_mode: bool, seed_bytes: [u8; 64],
runtime: Arc<Runtime>, logger: Arc<Logger>, kv_store: Arc<DynStore>,
async_payments_role: Option<AsyncPaymentsRole>, 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 @@ -1460,6 +1478,7 @@ fn build_with_store_internal(
rpc_user,
rpc_password,
rest_client_config,
..
}) => match rest_client_config {
Some(rest_client_config) => runtime.block_on(async {
ChainSource::new_bitcoind_rest(
Expand Down Expand Up @@ -1513,6 +1532,12 @@ fn build_with_store_internal(
},
};
let chain_source = Arc::new(chain_source);
let wallet_rescan_from_height = match chain_data_source_config {
Some(ChainDataSourceConfig::Bitcoind { wallet_rescan_from_height, .. }) => {
*wallet_rescan_from_height
},
_ => None,
};

// Initialize the on-chain wallet and chain access
let xprv = bitcoin::bip32::Xpriv::new_master(config.network, &seed_bytes).map_err(|e| {
Expand Down Expand Up @@ -1555,8 +1580,33 @@ fn build_with_store_internal(
},
})?;
let bdk_wallet = match wallet_opt {
Some(wallet) => wallet,
Some(wallet) => {
// `wallet_rescan_from_height`, when set, is fresh-wallet-only. Rewinding a
// persisted wallet is not just replacing BDK's best block: its local-chain and
// tx-graph changesets are already persisted, and LDK state may also have synced
// to a later tip. A safe rewind needs an explicit recovery flow that invalidates
// all dependent state before replaying blocks.
wallet
},
None => {
// Guard against silently setting the wallet birthday to genesis on a fresh node:
// if we are creating a new wallet but failed to learn the current chain tip from
// a Bitcoin Core RPC/REST backend, we'd otherwise persist fresh wallet state
// pinned at height 0 and force a full-history rescan once the backend comes back.
// Abort cleanly instead so the misconfiguration surfaces on the first startup.
// Esplora/Electrum backends currently never return a tip at build time, so they
// retain their existing behavior.
if wallet_rescan_from_height.is_none()
&& chain_tip_opt.is_none()
&& matches!(chain_data_source_config, Some(ChainDataSourceConfig::Bitcoind { .. }))
{
log_error!(
logger,
"Failed to determine chain tip on first startup. Aborting to avoid pinning the wallet birthday to genesis."
);
return Err(BuildError::ChainTipFetchFailed);
}

let mut wallet = runtime
.block_on(async {
BdkWallet::create(descriptor, change_descriptor)
Expand All @@ -1569,23 +1619,67 @@ fn build_with_store_internal(
BuildError::WalletSetupFailed
})?;

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.
let mut latest_checkpoint = wallet.latest_checkpoint();
let block_id = bdk_chain::BlockId {
height: best_block.height,
hash: best_block.block_hash,
};
latest_checkpoint = latest_checkpoint.insert(block_id);
let update =
bdk_wallet::Update { chain: Some(latest_checkpoint), ..Default::default() };
wallet.apply_update(update).map_err(|e| {
log_error!(logger, "Failed to apply checkpoint during wallet setup: {}", e);
// Decide which block (if any) to insert as the initial BDK checkpoint. If the
// bitcoind config provides a wallet rescan height, resolve that block and use it as
// the checkpoint. Otherwise, use the current chain tip to avoid any rescan.
let checkpoint_block = match wallet_rescan_from_height {
None => chain_tip_opt,
Some(height) => {
if let Some(chain_tip) = chain_tip_opt {
if height > chain_tip.height {
log_error!(
logger,
"Wallet rescan height {} is above current chain tip {}.",
height,
chain_tip.height
);
return Err(BuildError::WalletRescanHeightTooHigh);
}
}

let utxo_source = chain_source.as_utxo_source().ok_or_else(|| {
log_error!(
logger,
"Wallet rescan height requested but the chain source does not support block-by-height lookups.",
);
BuildError::WalletSetupFailed
})?;
}
let hash_res = runtime.block_on(async {
lightning_block_sync::gossip::UtxoSource::get_block_hash_by_height(
&utxo_source,
height,
)
.await
});
match hash_res {
Ok(hash) => Some(BlockLocator::new(hash, height)),
Err(e) => {
log_error!(
logger,
"Failed to resolve block hash at height {} for wallet rescan: {:?}",
height,
e,
);
return Err(BuildError::WalletSetupFailed);
},
}
},
};

if let Some(best_block) = checkpoint_block {
// Insert the checkpoint so BDK starts scanning from there instead of from
// genesis.
// TODO: Use a proper wallet birthday once BDK supports it.
let mut latest_checkpoint = wallet.latest_checkpoint();
let block_id =
bdk_chain::BlockId { height: best_block.height, hash: best_block.block_hash };
latest_checkpoint = latest_checkpoint.insert(block_id);
let update =
bdk_wallet::Update { chain: Some(latest_checkpoint), ..Default::default() };
wallet.apply_update(update).map_err(|e| {
log_error!(logger, "Failed to apply checkpoint during wallet setup: {}", e);
BuildError::WalletSetupFailed
})?;
}
wallet
},
Expand Down
Loading
Loading