Skip to content

Commit f2e44fd

Browse files
authored
Merge pull request #884 from tnull/2026-04-abort-on-first-startup-tip-fetch-failure
Allow wallet imports from arbitrary heights, forcing full scans
2 parents c7cb7ef + 101c827 commit f2e44fd

8 files changed

Lines changed: 433 additions & 66 deletions

File tree

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,23 @@
66
- Users of the VSS storage backend must upgrade their VSS server to at least version
77
`v0.1.0-alpha.0` before upgrading LDK Node.
88

9+
## Feature and API updates
10+
- The Bitcoin Core RPC and REST chain-source builder methods now accept an optional
11+
`wallet_rescan_from_height` argument. Passing a height lets fresh wallets rescan from a known
12+
birthday block instead of checkpointing at the current tip, which is useful when restoring a
13+
wallet on a pruned node where the full history is unavailable but the wallet birthday height is
14+
known. Existing wallets are not rewound, and future heights fail the build. Passing `Some(0)`
15+
rescans from genesis; passing `None` keeps the default current-tip checkpoint behavior. (#884)
16+
- `EsploraSyncConfig` and `ElectrumSyncConfig` now support `force_wallet_full_scan`. When set,
17+
the on-chain wallet keeps using BDK `full_scan` instead of incremental sync until a full scan
18+
succeeds, allowing restored wallets to rediscover funds sent to previously-unknown addresses.
19+
20+
## Bug Fixes and Improvements
21+
- Building a fresh node against a Bitcoin Core RPC or REST chain source that fails to return the
22+
current chain tip now aborts with a new `BuildError::ChainTipFetchFailed` variant instead of
23+
silently pinning the wallet birthday to genesis, which would have forced a full-history rescan
24+
once the chain source became reachable again. (#884)
25+
926
# 0.7.0 - Dec. 3, 2025
1027
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.
1128

bindings/ldk_node.udl

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,8 @@ interface Builder {
3838
constructor(Config config);
3939
void set_chain_source_esplora(string server_url, EsploraSyncConfig? config);
4040
void set_chain_source_electrum(string server_url, ElectrumSyncConfig? config);
41-
void set_chain_source_bitcoind_rpc(string rpc_host, u16 rpc_port, string rpc_user, string rpc_password);
42-
void set_chain_source_bitcoind_rest(string rest_host, u16 rest_port, string rpc_host, u16 rpc_port, string rpc_user, string rpc_password);
41+
void set_chain_source_bitcoind_rpc(string rpc_host, u16 rpc_port, string rpc_user, string rpc_password, u32? wallet_rescan_from_height);
42+
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);
4343
void set_gossip_source_p2p();
4444
void set_gossip_source_rgs(string rgs_server_url);
4545
void set_pathfinding_scores_source(string url);
@@ -59,7 +59,6 @@ interface Builder {
5959
void set_node_alias(string node_alias);
6060
[Throws=BuildError]
6161
void set_async_payments_role(AsyncPaymentsRole? role);
62-
void set_wallet_recovery_mode();
6362
[Throws=BuildError]
6463
Node build(NodeEntropy node_entropy);
6564
[Throws=BuildError]

src/builder.rs

Lines changed: 137 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ enum ChainDataSourceConfig {
105105
rpc_user: String,
106106
rpc_password: String,
107107
rest_client_config: Option<BitcoindRestClientConfig>,
108+
wallet_rescan_from_height: Option<u32>,
108109
},
109110
}
110111

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

201211
impl fmt::Display for BuildError {
@@ -233,6 +243,15 @@ impl fmt::Display for BuildError {
233243
Self::DNSResolverSetupFailed => {
234244
write!(f, "An attempt to setup a DNS resolver has failed.")
235245
},
246+
Self::ChainTipFetchFailed => {
247+
write!(
248+
f,
249+
"Failed to determine the current chain tip on first startup. Verify the chain data source is reachable and correctly configured."
250+
)
251+
},
252+
Self::WalletRescanHeightTooHigh => {
253+
write!(f, "Wallet rescan height is above the current chain tip.")
254+
},
236255
}
237256
}
238257
}
@@ -287,7 +306,6 @@ pub struct NodeBuilder {
287306
async_payments_role: Option<AsyncPaymentsRole>,
288307
runtime_handle: Option<tokio::runtime::Handle>,
289308
pathfinding_scores_sync_config: Option<PathfindingScoresSyncConfig>,
290-
recovery_mode: bool,
291309
}
292310

293311
impl NodeBuilder {
@@ -305,7 +323,6 @@ impl NodeBuilder {
305323
let log_writer_config = None;
306324
let runtime_handle = None;
307325
let pathfinding_scores_sync_config = None;
308-
let recovery_mode = false;
309326
Self {
310327
config,
311328
chain_data_source_config,
@@ -315,7 +332,6 @@ impl NodeBuilder {
315332
runtime_handle,
316333
async_payments_role: None,
317334
pathfinding_scores_sync_config,
318-
recovery_mode,
319335
}
320336
}
321337

@@ -380,15 +396,21 @@ impl NodeBuilder {
380396
/// ## Parameters:
381397
/// * `rpc_host`, `rpc_port`, `rpc_user`, `rpc_password` - Required parameters for the Bitcoin Core RPC
382398
/// connection.
399+
/// * `wallet_rescan_from_height` - Optional wallet birthday height to rescan from on first
400+
/// startup, before wallet state exists. Existing wallets are not rewound. The height must
401+
/// be at or below the current tip. Passing `Some(0)` rescans from genesis; passing `None`
402+
/// checkpoints at the current tip.
383403
pub fn set_chain_source_bitcoind_rpc(
384404
&mut self, rpc_host: String, rpc_port: u16, rpc_user: String, rpc_password: String,
405+
wallet_rescan_from_height: Option<u32>,
385406
) -> &mut Self {
386407
self.chain_data_source_config = Some(ChainDataSourceConfig::Bitcoind {
387408
rpc_host,
388409
rpc_port,
389410
rpc_user,
390411
rpc_password,
391412
rest_client_config: None,
413+
wallet_rescan_from_height,
392414
});
393415
self
394416
}
@@ -402,16 +424,21 @@ impl NodeBuilder {
402424
/// * `rest_host`, `rest_port` - Required parameters for the Bitcoin Core REST connection.
403425
/// * `rpc_host`, `rpc_port`, `rpc_user`, `rpc_password` - Required parameters for the Bitcoin Core RPC
404426
/// connection
427+
/// * `wallet_rescan_from_height` - Optional wallet birthday height to rescan from on first
428+
/// startup, before wallet state exists. Existing wallets are not rewound. The height must
429+
/// be at or below the current tip. Passing `Some(0)` rescans from genesis; passing `None`
430+
/// checkpoints at the current tip.
405431
pub fn set_chain_source_bitcoind_rest(
406432
&mut self, rest_host: String, rest_port: u16, rpc_host: String, rpc_port: u16,
407-
rpc_user: String, rpc_password: String,
433+
rpc_user: String, rpc_password: String, wallet_rescan_from_height: Option<u32>,
408434
) -> &mut Self {
409435
self.chain_data_source_config = Some(ChainDataSourceConfig::Bitcoind {
410436
rpc_host,
411437
rpc_port,
412438
rpc_user,
413439
rpc_password,
414440
rest_client_config: Some(BitcoindRestClientConfig { rest_host, rest_port }),
441+
wallet_rescan_from_height,
415442
});
416443

417444
self
@@ -602,16 +629,6 @@ impl NodeBuilder {
602629
Ok(self)
603630
}
604631

605-
/// Configures the [`Node`] to resync chain data from genesis on first startup, recovering any
606-
/// historical wallet funds.
607-
///
608-
/// This should only be set on first startup when importing an older wallet from a previously
609-
/// used [`NodeEntropy`].
610-
pub fn set_wallet_recovery_mode(&mut self) -> &mut Self {
611-
self.recovery_mode = true;
612-
self
613-
}
614-
615632
/// Builds a [`Node`] instance with a [`SqliteStore`] backend and according to the options
616633
/// previously configured.
617634
pub fn build(&self, node_entropy: NodeEntropy) -> Result<Node, BuildError> {
@@ -852,7 +869,6 @@ impl NodeBuilder {
852869
self.liquidity_source_config.as_ref(),
853870
self.pathfinding_scores_sync_config.as_ref(),
854871
self.async_payments_role,
855-
self.recovery_mode,
856872
seed_bytes,
857873
runtime,
858874
logger,
@@ -966,14 +982,20 @@ impl ArcedNodeBuilder {
966982
/// ## Parameters:
967983
/// * `rpc_host`, `rpc_port`, `rpc_user`, `rpc_password` - Required parameters for the Bitcoin Core RPC
968984
/// connection.
985+
/// * `wallet_rescan_from_height` - Optional wallet birthday height to rescan from on first
986+
/// startup, before wallet state exists. Existing wallets are not rewound. The height must
987+
/// be at or below the current tip. Passing `Some(0)` rescans from genesis; passing `None`
988+
/// checkpoints at the current tip.
969989
pub fn set_chain_source_bitcoind_rpc(
970990
&self, rpc_host: String, rpc_port: u16, rpc_user: String, rpc_password: String,
991+
wallet_rescan_from_height: Option<u32>,
971992
) {
972993
self.inner.write().expect("lock").set_chain_source_bitcoind_rpc(
973994
rpc_host,
974995
rpc_port,
975996
rpc_user,
976997
rpc_password,
998+
wallet_rescan_from_height,
977999
);
9781000
}
9791001

@@ -986,9 +1008,13 @@ impl ArcedNodeBuilder {
9861008
/// * `rest_host`, `rest_port` - Required parameters for the Bitcoin Core REST connection.
9871009
/// * `rpc_host`, `rpc_port`, `rpc_user`, `rpc_password` - Required parameters for the Bitcoin Core RPC
9881010
/// connection
1011+
/// * `wallet_rescan_from_height` - Optional wallet birthday height to rescan from on first
1012+
/// startup, before wallet state exists. Existing wallets are not rewound. The height must
1013+
/// be at or below the current tip. Passing `Some(0)` rescans from genesis; passing `None`
1014+
/// checkpoints at the current tip.
9891015
pub fn set_chain_source_bitcoind_rest(
9901016
&self, rest_host: String, rest_port: u16, rpc_host: String, rpc_port: u16,
991-
rpc_user: String, rpc_password: String,
1017+
rpc_user: String, rpc_password: String, wallet_rescan_from_height: Option<u32>,
9921018
) {
9931019
self.inner.write().expect("lock").set_chain_source_bitcoind_rest(
9941020
rest_host,
@@ -997,6 +1023,7 @@ impl ArcedNodeBuilder {
9971023
rpc_port,
9981024
rpc_user,
9991025
rpc_password,
1026+
wallet_rescan_from_height,
10001027
);
10011028
}
10021029

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

1142-
/// Configures the [`Node`] to resync chain data from genesis on first startup, recovering any
1143-
/// historical wallet funds.
1144-
///
1145-
/// This should only be set on first startup when importing an older wallet from a previously
1146-
/// used [`NodeEntropy`].
1147-
pub fn set_wallet_recovery_mode(&self) {
1148-
self.inner.write().expect("lock").set_wallet_recovery_mode();
1149-
}
1150-
11511169
/// Builds a [`Node`] instance with a [`SqliteStore`] backend and according to the options
11521170
/// previously configured.
11531171
pub fn build(&self, node_entropy: Arc<NodeEntropy>) -> Result<Arc<Node>, BuildError> {
@@ -1343,8 +1361,8 @@ fn build_with_store_internal(
13431361
gossip_source_config: Option<&GossipSourceConfig>,
13441362
liquidity_source_config: Option<&LiquiditySourceConfig>,
13451363
pathfinding_scores_sync_config: Option<&PathfindingScoresSyncConfig>,
1346-
async_payments_role: Option<AsyncPaymentsRole>, recovery_mode: bool, seed_bytes: [u8; 64],
1347-
runtime: Arc<Runtime>, logger: Arc<Logger>, kv_store: Arc<DynStore>,
1364+
async_payments_role: Option<AsyncPaymentsRole>, seed_bytes: [u8; 64], runtime: Arc<Runtime>,
1365+
logger: Arc<Logger>, kv_store: Arc<DynStore>,
13481366
) -> Result<Node, BuildError> {
13491367
optionally_install_rustls_cryptoprovider();
13501368

@@ -1460,6 +1478,7 @@ fn build_with_store_internal(
14601478
rpc_user,
14611479
rpc_password,
14621480
rest_client_config,
1481+
..
14631482
}) => match rest_client_config {
14641483
Some(rest_client_config) => runtime.block_on(async {
14651484
ChainSource::new_bitcoind_rest(
@@ -1513,6 +1532,12 @@ fn build_with_store_internal(
15131532
},
15141533
};
15151534
let chain_source = Arc::new(chain_source);
1535+
let wallet_rescan_from_height = match chain_data_source_config {
1536+
Some(ChainDataSourceConfig::Bitcoind { wallet_rescan_from_height, .. }) => {
1537+
*wallet_rescan_from_height
1538+
},
1539+
_ => None,
1540+
};
15161541

15171542
// Initialize the on-chain wallet and chain access
15181543
let xprv = bitcoin::bip32::Xpriv::new_master(config.network, &seed_bytes).map_err(|e| {
@@ -1555,8 +1580,33 @@ fn build_with_store_internal(
15551580
},
15561581
})?;
15571582
let bdk_wallet = match wallet_opt {
1558-
Some(wallet) => wallet,
1583+
Some(wallet) => {
1584+
// `wallet_rescan_from_height`, when set, is fresh-wallet-only. Rewinding a
1585+
// persisted wallet is not just replacing BDK's best block: its local-chain and
1586+
// tx-graph changesets are already persisted, and LDK state may also have synced
1587+
// to a later tip. A safe rewind needs an explicit recovery flow that invalidates
1588+
// all dependent state before replaying blocks.
1589+
wallet
1590+
},
15591591
None => {
1592+
// Guard against silently setting the wallet birthday to genesis on a fresh node:
1593+
// if we are creating a new wallet but failed to learn the current chain tip from
1594+
// a Bitcoin Core RPC/REST backend, we'd otherwise persist fresh wallet state
1595+
// pinned at height 0 and force a full-history rescan once the backend comes back.
1596+
// Abort cleanly instead so the misconfiguration surfaces on the first startup.
1597+
// Esplora/Electrum backends currently never return a tip at build time, so they
1598+
// retain their existing behavior.
1599+
if wallet_rescan_from_height.is_none()
1600+
&& chain_tip_opt.is_none()
1601+
&& matches!(chain_data_source_config, Some(ChainDataSourceConfig::Bitcoind { .. }))
1602+
{
1603+
log_error!(
1604+
logger,
1605+
"Failed to determine chain tip on first startup. Aborting to avoid pinning the wallet birthday to genesis."
1606+
);
1607+
return Err(BuildError::ChainTipFetchFailed);
1608+
}
1609+
15601610
let mut wallet = runtime
15611611
.block_on(async {
15621612
BdkWallet::create(descriptor, change_descriptor)
@@ -1569,23 +1619,67 @@ fn build_with_store_internal(
15691619
BuildError::WalletSetupFailed
15701620
})?;
15711621

1572-
if !recovery_mode {
1573-
if let Some(best_block) = chain_tip_opt {
1574-
// Insert the first checkpoint if we have it, to avoid resyncing from genesis.
1575-
// TODO: Use a proper wallet birthday once BDK supports it.
1576-
let mut latest_checkpoint = wallet.latest_checkpoint();
1577-
let block_id = bdk_chain::BlockId {
1578-
height: best_block.height,
1579-
hash: best_block.block_hash,
1580-
};
1581-
latest_checkpoint = latest_checkpoint.insert(block_id);
1582-
let update =
1583-
bdk_wallet::Update { chain: Some(latest_checkpoint), ..Default::default() };
1584-
wallet.apply_update(update).map_err(|e| {
1585-
log_error!(logger, "Failed to apply checkpoint during wallet setup: {}", e);
1622+
// Decide which block (if any) to insert as the initial BDK checkpoint. If the
1623+
// bitcoind config provides a wallet rescan height, resolve that block and use it as
1624+
// the checkpoint. Otherwise, use the current chain tip to avoid any rescan.
1625+
let checkpoint_block = match wallet_rescan_from_height {
1626+
None => chain_tip_opt,
1627+
Some(height) => {
1628+
if let Some(chain_tip) = chain_tip_opt {
1629+
if height > chain_tip.height {
1630+
log_error!(
1631+
logger,
1632+
"Wallet rescan height {} is above current chain tip {}.",
1633+
height,
1634+
chain_tip.height
1635+
);
1636+
return Err(BuildError::WalletRescanHeightTooHigh);
1637+
}
1638+
}
1639+
1640+
let utxo_source = chain_source.as_utxo_source().ok_or_else(|| {
1641+
log_error!(
1642+
logger,
1643+
"Wallet rescan height requested but the chain source does not support block-by-height lookups.",
1644+
);
15861645
BuildError::WalletSetupFailed
15871646
})?;
1588-
}
1647+
let hash_res = runtime.block_on(async {
1648+
lightning_block_sync::gossip::UtxoSource::get_block_hash_by_height(
1649+
&utxo_source,
1650+
height,
1651+
)
1652+
.await
1653+
});
1654+
match hash_res {
1655+
Ok(hash) => Some(BlockLocator::new(hash, height)),
1656+
Err(e) => {
1657+
log_error!(
1658+
logger,
1659+
"Failed to resolve block hash at height {} for wallet rescan: {:?}",
1660+
height,
1661+
e,
1662+
);
1663+
return Err(BuildError::WalletSetupFailed);
1664+
},
1665+
}
1666+
},
1667+
};
1668+
1669+
if let Some(best_block) = checkpoint_block {
1670+
// Insert the checkpoint so BDK starts scanning from there instead of from
1671+
// genesis.
1672+
// TODO: Use a proper wallet birthday once BDK supports it.
1673+
let mut latest_checkpoint = wallet.latest_checkpoint();
1674+
let block_id =
1675+
bdk_chain::BlockId { height: best_block.height, hash: best_block.block_hash };
1676+
latest_checkpoint = latest_checkpoint.insert(block_id);
1677+
let update =
1678+
bdk_wallet::Update { chain: Some(latest_checkpoint), ..Default::default() };
1679+
wallet.apply_update(update).map_err(|e| {
1680+
log_error!(logger, "Failed to apply checkpoint during wallet setup: {}", e);
1681+
BuildError::WalletSetupFailed
1682+
})?;
15891683
}
15901684
wallet
15911685
},

0 commit comments

Comments
 (0)