Skip to content

Commit b812128

Browse files
authored
Merge pull request #1069 from tankyleo/2026-08-rescan-height-inclusive
Rescan the `wallet_rescan_from_height` block
2 parents 6480bff + 94b3ada commit b812128

2 files changed

Lines changed: 64 additions & 39 deletions

File tree

src/builder.rs

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -427,9 +427,9 @@ impl NodeBuilder {
427427
/// ## Parameters:
428428
/// * `rpc_host`, `rpc_port`, `rpc_user`, `rpc_password` - Required parameters for the Bitcoin Core RPC
429429
/// connection.
430-
/// * `wallet_rescan_from_height` - Optional wallet birthday height to rescan from on first
431-
/// startup, before wallet state exists. Existing wallets are not rewound. The height must
432-
/// be at or below the current tip. Passing `Some(0)` rescans from genesis; passing `None`
430+
/// * `wallet_rescan_from_height` - Optional wallet birthday height to rescan from (inclusive) on
431+
/// first startup, before wallet state exists. Existing wallets are not rewound. The height
432+
/// must be at or below the current tip. Passing `Some(0)` rescans from genesis; passing `None`
433433
/// checkpoints at the current tip.
434434
#[cfg(feature = "chain-bitcoind")]
435435
pub fn set_chain_source_bitcoind_rpc(
@@ -456,9 +456,9 @@ impl NodeBuilder {
456456
/// * `rest_host`, `rest_port` - Required parameters for the Bitcoin Core REST connection.
457457
/// * `rpc_host`, `rpc_port`, `rpc_user`, `rpc_password` - Required parameters for the Bitcoin Core RPC
458458
/// connection
459-
/// * `wallet_rescan_from_height` - Optional wallet birthday height to rescan from on first
460-
/// startup, before wallet state exists. Existing wallets are not rewound. The height must
461-
/// be at or below the current tip. Passing `Some(0)` rescans from genesis; passing `None`
459+
/// * `wallet_rescan_from_height` - Optional wallet birthday height to rescan from (inclusive) on
460+
/// first startup, before wallet state exists. Existing wallets are not rewound. The height
461+
/// must be at or below the current tip. Passing `Some(0)` rescans from genesis; passing `None`
462462
/// checkpoints at the current tip.
463463
#[cfg(feature = "chain-bitcoind")]
464464
pub fn set_chain_source_bitcoind_rest(
@@ -1075,9 +1075,9 @@ impl Builder {
10751075
/// ## Parameters:
10761076
/// * `rpc_host`, `rpc_port`, `rpc_user`, `rpc_password` - Required parameters for the Bitcoin Core RPC
10771077
/// connection.
1078-
/// * `wallet_rescan_from_height` - Optional wallet birthday height to rescan from on first
1079-
/// startup, before wallet state exists. Existing wallets are not rewound. The height must
1080-
/// be at or below the current tip. Passing `Some(0)` rescans from genesis; passing `None`
1078+
/// * `wallet_rescan_from_height` - Optional wallet birthday height to rescan from (inclusive) on
1079+
/// first startup, before wallet state exists. Existing wallets are not rewound. The height
1080+
/// must be at or below the current tip. Passing `Some(0)` rescans from genesis; passing `None`
10811081
/// checkpoints at the current tip.
10821082
pub fn set_chain_source_bitcoind_rpc(
10831083
&self, rpc_host: String, rpc_port: u16, rpc_user: String, rpc_password: String,
@@ -1101,9 +1101,9 @@ impl Builder {
11011101
/// * `rest_host`, `rest_port` - Required parameters for the Bitcoin Core REST connection.
11021102
/// * `rpc_host`, `rpc_port`, `rpc_user`, `rpc_password` - Required parameters for the Bitcoin Core RPC
11031103
/// connection
1104-
/// * `wallet_rescan_from_height` - Optional wallet birthday height to rescan from on first
1105-
/// startup, before wallet state exists. Existing wallets are not rewound. The height must
1106-
/// be at or below the current tip. Passing `Some(0)` rescans from genesis; passing `None`
1104+
/// * `wallet_rescan_from_height` - Optional wallet birthday height to rescan from (inclusive) on
1105+
/// first startup, before wallet state exists. Existing wallets are not rewound. The height
1106+
/// must be at or below the current tip. Passing `Some(0)` rescans from genesis; passing `None`
11071107
/// checkpoints at the current tip.
11081108
pub fn set_chain_source_bitcoind_rest(
11091109
&self, rest_host: String, rest_port: u16, rpc_host: String, rpc_port: u16,
@@ -1771,8 +1771,9 @@ fn build_with_store_internal(
17711771
})?;
17721772

17731773
// Decide which block (if any) to insert as the initial BDK checkpoint. If the
1774-
// bitcoind config provides a wallet rescan height, resolve that block and use it as
1775-
// the checkpoint. Otherwise, use the current chain tip to avoid any rescan.
1774+
// bitcoind config provides a wallet rescan height, use the preceding block as the
1775+
// checkpoint so synchronization includes the requested height. Otherwise, use the
1776+
// current chain tip to avoid any rescan.
17761777
let checkpoint_block = match wallet_rescan_from_height {
17771778
None => chain_tip_opt,
17781779
#[cfg(feature = "chain-bitcoind")]
@@ -1788,6 +1789,10 @@ fn build_with_store_internal(
17881789
return Err(BuildError::WalletRescanHeightTooHigh);
17891790
}
17901791
}
1792+
// `synchronize_listeners` connects blocks strictly above each listener's
1793+
// checkpoint. The genesis block is already BDK's initial checkpoint, so
1794+
// saturating subtraction also handles a requested height of zero.
1795+
let checkpoint_height = height.saturating_sub(1);
17911796

17921797
let utxo_source = chain_source.as_utxo_source().ok_or_else(|| {
17931798
log_error!(
@@ -1799,16 +1804,17 @@ fn build_with_store_internal(
17991804
let hash_res = runtime.block_on(async {
18001805
lightning_block_sync::gossip::UtxoSource::get_block_hash_by_height(
18011806
&utxo_source,
1802-
height,
1807+
checkpoint_height,
18031808
)
18041809
.await
18051810
});
18061811
match hash_res {
1807-
Ok(hash) => Some(BlockLocator::new(hash, height)),
1812+
Ok(hash) => Some(BlockLocator::new(hash, checkpoint_height)),
18081813
Err(e) => {
18091814
log_error!(
18101815
logger,
1811-
"Failed to resolve block hash at height {} for wallet rescan: {:?}",
1816+
"Failed to resolve checkpoint block hash at height {} for wallet rescan from height {}: {:?}",
1817+
checkpoint_height,
18121818
height,
18131819
e,
18141820
);

tests/integration_tests_rust.rs

Lines changed: 41 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1557,19 +1557,20 @@ async fn do_onchain_wallet_full_scan_stop_gap_recovers_far_funds(
15571557

15581558
#[cfg(feature = "chain-bitcoind")]
15591559
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
1560-
async fn onchain_wallet_recovery_rescans_from_birthday_height() {
1560+
async fn onchain_wallet_recovery_includes_rescan_height() {
15611561
// End-to-end test for `wallet_rescan_from_height` against a bitcoind chain source. The
15621562
// scenario:
15631563
//
15641564
// 1. Create a node at some "birthday" height and generate two receive addresses.
15651565
// 2. Shut the node down and drop all persisted state except the seed.
15661566
// 3. Advance the chain past the birthday.
1567-
// 4. Send funds to the addresses generated at the birthday height and confirm them.
1567+
// 4. Send funds to both addresses and confirm both transactions in the same block.
15681568
// 5. Restart a fresh node with just the seed and no rescan height. Its wallet birthday
1569-
// is pinned at the current tip, which is above the blocks containing the funding
1570-
// transactions — so the node must not see the funds.
1571-
// 6. Restart again with `wallet_rescan_from_height: Some(birthday)`. Now the wallet must
1572-
// find and report both funding transactions.
1569+
// is pinned at the funding block, so the node must not see the funds.
1570+
// 6. Restart with the original birthday as `wallet_rescan_from_height` and confirm both
1571+
// transactions are found, preserving the previous test case.
1572+
// 7. Restart again with the funding block itself as `wallet_rescan_from_height` and confirm
1573+
// both transactions are found, proving that the configured height is inclusive.
15731574
let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();
15741575
// We specifically exercise the bitcoind RPC backend because that's where
15751576
// `rescan_from_height` is honored precisely (via `get_block_hash_by_height`).
@@ -1587,7 +1588,6 @@ async fn onchain_wallet_recovery_rescans_from_birthday_height() {
15871588

15881589
let addr_1 = original_node.onchain_payment().new_address().unwrap();
15891590
let addr_2 = original_node.onchain_payment().new_address().unwrap();
1590-
15911591
let birthday_height: u32 = bitcoind
15921592
.client
15931593
.get_blockchain_info()
@@ -1600,11 +1600,11 @@ async fn onchain_wallet_recovery_rescans_from_birthday_height() {
16001600
original_node.stop().unwrap();
16011601
drop(original_node);
16021602

1603-
// Step 3: advance the chain past the birthday, so a fresh node would otherwise pin its
1604-
// wallet birthday at a height above the funding transactions in step 4.
1603+
// Step 3: advance the chain past the original wallet birthday.
16051604
generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 10).await;
16061605

1607-
// Step 4: fund both addresses and confirm them.
1606+
// Step 4: queue both transactions before mining so they are confirmed in the same block, as
1607+
// exercised by the previous version of this test.
16081608
let txid_1 = bitcoind
16091609
.client
16101610
.send_to_address(&addr_1, Amount::from_sat(premine_amount_sat))
@@ -1621,33 +1621,52 @@ async fn onchain_wallet_recovery_rescans_from_birthday_height() {
16211621
.parse()
16221622
.unwrap();
16231623
wait_for_tx(&electrsd.client, txid_2).await;
1624+
let funding_height: u32 =
1625+
(bitcoind.client.get_blockchain_info().expect("failed to get blockchain info").blocks + 1)
1626+
.try_into()
1627+
.unwrap();
16241628
generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await;
16251629

1626-
// Step 5: restart a fresh node with only the seed and no rescan height. It must NOT see
1627-
// the funds, because its wallet birthday sits above the funding transactions.
1630+
// Step 5: restart a fresh node with only the seed and no rescan height. It must NOT see the
1631+
// funds, because its wallet birthday is the block containing both funding transactions.
16281632
let mut pinned_config = random_config();
16291633
pinned_config.node_entropy = original_node_entropy;
16301634
let pinned_node = setup_node(&chain_source, pinned_config);
16311635
pinned_node.sync_wallets().unwrap();
16321636
assert_eq!(
16331637
pinned_node.list_balances().spendable_onchain_balance_sats,
16341638
0,
1635-
"fresh node without rescan height should not find funds below its wallet birthday"
1639+
"fresh node without rescan height should not scan its wallet birthday block"
16361640
);
16371641
pinned_node.stop().unwrap();
16381642
drop(pinned_node);
16391643

1640-
// Step 6: restart with a rescan height set to the birthday height. Funds must be
1641-
// re-discovered.
1642-
let mut recovered_config = random_config();
1643-
recovered_config.node_entropy = original_node_entropy;
1644-
recovered_config.wallet_rescan_from_height = Some(birthday_height);
1645-
let recovered_node = setup_node(&chain_source, recovered_config);
1646-
recovered_node.sync_wallets().unwrap();
1644+
// Step 6: recover from the original wallet birthday, retaining the previous test's case where
1645+
// both transactions are found 11 blocks after the configured rescan height.
1646+
let mut birthday_recovery_config = random_config();
1647+
birthday_recovery_config.node_entropy = original_node_entropy;
1648+
birthday_recovery_config.wallet_rescan_from_height = Some(birthday_height);
1649+
let birthday_recovered_node = setup_node(&chain_source, birthday_recovery_config);
1650+
birthday_recovered_node.sync_wallets().unwrap();
16471651
assert_eq!(
1648-
recovered_node.list_balances().spendable_onchain_balance_sats,
1652+
birthday_recovered_node.list_balances().spendable_onchain_balance_sats,
1653+
premine_amount_sat * 2,
1654+
"node recovered from the wallet birthday should find both same-block transactions"
1655+
);
1656+
birthday_recovered_node.stop().unwrap();
1657+
drop(birthday_recovered_node);
1658+
1659+
// Step 7: recover from the funding block itself. Both transactions must still be found, proving
1660+
// that `wallet_rescan_from_height` is inclusive.
1661+
let mut funding_block_recovery_config = random_config();
1662+
funding_block_recovery_config.node_entropy = original_node_entropy;
1663+
funding_block_recovery_config.wallet_rescan_from_height = Some(funding_height);
1664+
let funding_block_recovered_node = setup_node(&chain_source, funding_block_recovery_config);
1665+
funding_block_recovered_node.sync_wallets().unwrap();
1666+
assert_eq!(
1667+
funding_block_recovered_node.list_balances().spendable_onchain_balance_sats,
16491668
premine_amount_sat * 2,
1650-
"node recovered with rescan_from_height should see funds sent to pre-birthday addresses"
1669+
"node recovered from the funding block should find both transactions in that block"
16511670
);
16521671
}
16531672

0 commit comments

Comments
 (0)