Skip to content

Commit 72d2414

Browse files
committed
Add bitcoind wallet rescan height
Allow Bitcoin Core RPC and REST chain-source configuration to specify the wallet birthday height used when creating a fresh wallet. This lets restored wallets rescan from a known height, including genesis, without overloading a global recovery toggle. Reject requested heights above the current chain tip with an explicit build error before wallet state is created. Existing wallets are not rewound by this option because a safe rewind must invalidate persisted wallet and LDK state before replaying blocks. Co-Authored-By: HAL 9000
1 parent d0ed6a3 commit 72d2414

5 files changed

Lines changed: 262 additions & 63 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@
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+
917
## Bug Fixes and Improvements
1018
- Building a fresh node against a Bitcoin Core RPC or REST chain source that fails to return the
1119
current chain tip now aborts with a new `BuildError::ChainTipFetchFailed` variant instead of

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: 110 additions & 46 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

@@ -203,6 +204,8 @@ pub enum BuildError {
203204
/// wallet birthday. Falling back to genesis would silently force a full-history rescan on
204205
/// the next successful startup, so we abort instead.
205206
ChainTipFetchFailed,
207+
/// The configured wallet rescan height is above the current chain tip.
208+
WalletRescanHeightTooHigh,
206209
}
207210

208211
impl fmt::Display for BuildError {
@@ -246,6 +249,9 @@ impl fmt::Display for BuildError {
246249
"Failed to determine the current chain tip on first startup. Verify the chain data source is reachable and correctly configured."
247250
)
248251
},
252+
Self::WalletRescanHeightTooHigh => {
253+
write!(f, "Wallet rescan height is above the current chain tip.")
254+
},
249255
}
250256
}
251257
}
@@ -300,7 +306,6 @@ pub struct NodeBuilder {
300306
async_payments_role: Option<AsyncPaymentsRole>,
301307
runtime_handle: Option<tokio::runtime::Handle>,
302308
pathfinding_scores_sync_config: Option<PathfindingScoresSyncConfig>,
303-
recovery_mode: bool,
304309
}
305310

306311
impl NodeBuilder {
@@ -318,7 +323,6 @@ impl NodeBuilder {
318323
let log_writer_config = None;
319324
let runtime_handle = None;
320325
let pathfinding_scores_sync_config = None;
321-
let recovery_mode = false;
322326
Self {
323327
config,
324328
chain_data_source_config,
@@ -328,7 +332,6 @@ impl NodeBuilder {
328332
runtime_handle,
329333
async_payments_role: None,
330334
pathfinding_scores_sync_config,
331-
recovery_mode,
332335
}
333336
}
334337

@@ -393,15 +396,21 @@ impl NodeBuilder {
393396
/// ## Parameters:
394397
/// * `rpc_host`, `rpc_port`, `rpc_user`, `rpc_password` - Required parameters for the Bitcoin Core RPC
395398
/// 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.
396403
pub fn set_chain_source_bitcoind_rpc(
397404
&mut self, rpc_host: String, rpc_port: u16, rpc_user: String, rpc_password: String,
405+
wallet_rescan_from_height: Option<u32>,
398406
) -> &mut Self {
399407
self.chain_data_source_config = Some(ChainDataSourceConfig::Bitcoind {
400408
rpc_host,
401409
rpc_port,
402410
rpc_user,
403411
rpc_password,
404412
rest_client_config: None,
413+
wallet_rescan_from_height,
405414
});
406415
self
407416
}
@@ -415,16 +424,21 @@ impl NodeBuilder {
415424
/// * `rest_host`, `rest_port` - Required parameters for the Bitcoin Core REST connection.
416425
/// * `rpc_host`, `rpc_port`, `rpc_user`, `rpc_password` - Required parameters for the Bitcoin Core RPC
417426
/// 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.
418431
pub fn set_chain_source_bitcoind_rest(
419432
&mut self, rest_host: String, rest_port: u16, rpc_host: String, rpc_port: u16,
420-
rpc_user: String, rpc_password: String,
433+
rpc_user: String, rpc_password: String, wallet_rescan_from_height: Option<u32>,
421434
) -> &mut Self {
422435
self.chain_data_source_config = Some(ChainDataSourceConfig::Bitcoind {
423436
rpc_host,
424437
rpc_port,
425438
rpc_user,
426439
rpc_password,
427440
rest_client_config: Some(BitcoindRestClientConfig { rest_host, rest_port }),
441+
wallet_rescan_from_height,
428442
});
429443

430444
self
@@ -615,16 +629,6 @@ impl NodeBuilder {
615629
Ok(self)
616630
}
617631

618-
/// Configures the [`Node`] to resync chain data from genesis on first startup, recovering any
619-
/// historical wallet funds.
620-
///
621-
/// This should only be set on first startup when importing an older wallet from a previously
622-
/// used [`NodeEntropy`].
623-
pub fn set_wallet_recovery_mode(&mut self) -> &mut Self {
624-
self.recovery_mode = true;
625-
self
626-
}
627-
628632
/// Builds a [`Node`] instance with a [`SqliteStore`] backend and according to the options
629633
/// previously configured.
630634
pub fn build(&self, node_entropy: NodeEntropy) -> Result<Node, BuildError> {
@@ -865,7 +869,6 @@ impl NodeBuilder {
865869
self.liquidity_source_config.as_ref(),
866870
self.pathfinding_scores_sync_config.as_ref(),
867871
self.async_payments_role,
868-
self.recovery_mode,
869872
seed_bytes,
870873
runtime,
871874
logger,
@@ -979,14 +982,20 @@ impl ArcedNodeBuilder {
979982
/// ## Parameters:
980983
/// * `rpc_host`, `rpc_port`, `rpc_user`, `rpc_password` - Required parameters for the Bitcoin Core RPC
981984
/// 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.
982989
pub fn set_chain_source_bitcoind_rpc(
983990
&self, rpc_host: String, rpc_port: u16, rpc_user: String, rpc_password: String,
991+
wallet_rescan_from_height: Option<u32>,
984992
) {
985993
self.inner.write().expect("lock").set_chain_source_bitcoind_rpc(
986994
rpc_host,
987995
rpc_port,
988996
rpc_user,
989997
rpc_password,
998+
wallet_rescan_from_height,
990999
);
9911000
}
9921001

@@ -999,9 +1008,13 @@ impl ArcedNodeBuilder {
9991008
/// * `rest_host`, `rest_port` - Required parameters for the Bitcoin Core REST connection.
10001009
/// * `rpc_host`, `rpc_port`, `rpc_user`, `rpc_password` - Required parameters for the Bitcoin Core RPC
10011010
/// 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.
10021015
pub fn set_chain_source_bitcoind_rest(
10031016
&self, rest_host: String, rest_port: u16, rpc_host: String, rpc_port: u16,
1004-
rpc_user: String, rpc_password: String,
1017+
rpc_user: String, rpc_password: String, wallet_rescan_from_height: Option<u32>,
10051018
) {
10061019
self.inner.write().expect("lock").set_chain_source_bitcoind_rest(
10071020
rest_host,
@@ -1010,6 +1023,7 @@ impl ArcedNodeBuilder {
10101023
rpc_port,
10111024
rpc_user,
10121025
rpc_password,
1026+
wallet_rescan_from_height,
10131027
);
10141028
}
10151029

@@ -1152,15 +1166,6 @@ impl ArcedNodeBuilder {
11521166
self.inner.write().expect("lock").set_async_payments_role(role).map(|_| ())
11531167
}
11541168

1155-
/// Configures the [`Node`] to resync chain data from genesis on first startup, recovering any
1156-
/// historical wallet funds.
1157-
///
1158-
/// This should only be set on first startup when importing an older wallet from a previously
1159-
/// used [`NodeEntropy`].
1160-
pub fn set_wallet_recovery_mode(&self) {
1161-
self.inner.write().expect("lock").set_wallet_recovery_mode();
1162-
}
1163-
11641169
/// Builds a [`Node`] instance with a [`SqliteStore`] backend and according to the options
11651170
/// previously configured.
11661171
pub fn build(&self, node_entropy: Arc<NodeEntropy>) -> Result<Arc<Node>, BuildError> {
@@ -1356,8 +1361,8 @@ fn build_with_store_internal(
13561361
gossip_source_config: Option<&GossipSourceConfig>,
13571362
liquidity_source_config: Option<&LiquiditySourceConfig>,
13581363
pathfinding_scores_sync_config: Option<&PathfindingScoresSyncConfig>,
1359-
async_payments_role: Option<AsyncPaymentsRole>, recovery_mode: bool, seed_bytes: [u8; 64],
1360-
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>,
13611366
) -> Result<Node, BuildError> {
13621367
optionally_install_rustls_cryptoprovider();
13631368

@@ -1473,6 +1478,7 @@ fn build_with_store_internal(
14731478
rpc_user,
14741479
rpc_password,
14751480
rest_client_config,
1481+
..
14761482
}) => match rest_client_config {
14771483
Some(rest_client_config) => runtime.block_on(async {
14781484
ChainSource::new_bitcoind_rest(
@@ -1526,6 +1532,12 @@ fn build_with_store_internal(
15261532
},
15271533
};
15281534
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+
};
15291541

15301542
// Initialize the on-chain wallet and chain access
15311543
let xprv = bitcoin::bip32::Xpriv::new_master(config.network, &seed_bytes).map_err(|e| {
@@ -1568,7 +1580,14 @@ fn build_with_store_internal(
15681580
},
15691581
})?;
15701582
let bdk_wallet = match wallet_opt {
1571-
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+
},
15721591
None => {
15731592
// Guard against silently setting the wallet birthday to genesis on a fresh node:
15741593
// if we are creating a new wallet but failed to learn the current chain tip from
@@ -1577,9 +1596,10 @@ fn build_with_store_internal(
15771596
// Abort cleanly instead so the misconfiguration surfaces on the first startup.
15781597
// Esplora/Electrum backends currently never return a tip at build time, so they
15791598
// retain their existing behavior.
1580-
let is_bitcoind_source =
1581-
matches!(chain_data_source_config, Some(ChainDataSourceConfig::Bitcoind { .. }));
1582-
if !recovery_mode && chain_tip_opt.is_none() && is_bitcoind_source {
1599+
if wallet_rescan_from_height.is_none()
1600+
&& chain_tip_opt.is_none()
1601+
&& matches!(chain_data_source_config, Some(ChainDataSourceConfig::Bitcoind { .. }))
1602+
{
15831603
log_error!(
15841604
logger,
15851605
"Failed to determine chain tip on first startup. Aborting to avoid pinning the wallet birthday to genesis."
@@ -1599,23 +1619,67 @@ fn build_with_store_internal(
15991619
BuildError::WalletSetupFailed
16001620
})?;
16011621

1602-
if !recovery_mode {
1603-
if let Some(best_block) = chain_tip_opt {
1604-
// Insert the first checkpoint if we have it, to avoid resyncing from genesis.
1605-
// TODO: Use a proper wallet birthday once BDK supports it.
1606-
let mut latest_checkpoint = wallet.latest_checkpoint();
1607-
let block_id = bdk_chain::BlockId {
1608-
height: best_block.height,
1609-
hash: best_block.block_hash,
1610-
};
1611-
latest_checkpoint = latest_checkpoint.insert(block_id);
1612-
let update =
1613-
bdk_wallet::Update { chain: Some(latest_checkpoint), ..Default::default() };
1614-
wallet.apply_update(update).map_err(|e| {
1615-
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+
);
16161645
BuildError::WalletSetupFailed
16171646
})?;
1618-
}
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+
})?;
16191683
}
16201684
wallet
16211685
},

0 commit comments

Comments
 (0)