From 0bb047f7b54bedc9428400ffc065c5c91de5e834 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 15 Jul 2026 12:41:36 +0200 Subject: [PATCH 1/4] Keep funding confirmed through test reorgs The test gave funding six confirmations while allowing six-block reorgs. That drops regular channel funding to zero confirmations and changes the scenario from a close reorg into a funding force-close. rust-lightning PR #4231 keeps trusted zero-conf channels open after funding is reorged out, but regular channels still force-close at zero confirmations. Mine one extra block so the deepest reorg leaves one confirmation. Preserve the CI counterexample that exposed this boundary. Co-Authored-By: HAL 9000 --- tests/reorg_test.proptest-regressions | 1 + tests/reorg_test.rs | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) create mode 100644 tests/reorg_test.proptest-regressions diff --git a/tests/reorg_test.proptest-regressions b/tests/reorg_test.proptest-regressions new file mode 100644 index 0000000000..eba903fd03 --- /dev/null +++ b/tests/reorg_test.proptest-regressions @@ -0,0 +1 @@ +cc 06354c9b049db51c31557bf46d86a68bdd4049577cbd9190fb81c7824b18f0e6 # shrinks to reorg_depth = 6, force_close = false diff --git a/tests/reorg_test.rs b/tests/reorg_test.rs index 295d9fdd24..10f1d44511 100644 --- a/tests/reorg_test.rs +++ b/tests/reorg_test.rs @@ -76,7 +76,9 @@ proptest! { nodes_funding_tx.insert(node.node_id(), funding_txo); } - generate_blocks_and_wait(bitcoind, electrs, 6).await; + // Keep funding confirmed across the deepest reorg. rust-lightning PR #4231 exempts + // only trusted zero-conf channels; regular channels still force-close at zero confirmations. + generate_blocks_and_wait(bitcoind, electrs, 7).await; sync_wallets!(); reorg!(reorg_depth); From 5ddf27061226de95255ff20160608355d99e1824 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 15 Jul 2026 12:43:26 +0200 Subject: [PATCH 2/4] Wait for replacement blocks after reorgs The block helper only compared heights. Replacing invalidated blocks with the same number of new blocks leaves the height unchanged, so it could return while Electrum still exposed the old chain. Wallet syncs then observed stale state and made reorg assertions timing-dependent. Require Electrum's target-height hash to match bitcoind's replacement block before returning. Co-Authored-By: HAL 9000 --- tests/common/mod.rs | 40 ++++++++++++++++----------------- tests/integration_tests_rust.rs | 4 ++-- 2 files changed, 21 insertions(+), 23 deletions(-) diff --git a/tests/common/mod.rs b/tests/common/mod.rs index aeacef464b..3e8b60fe43 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -753,7 +753,7 @@ pub(crate) async fn generate_blocks_and_wait( let address = bitcoind.new_address().expect("failed to get new address"); // TODO: expect this Result once the WouldBlock issue is resolved upstream. let _block_hashes_res = bitcoind.generate_to_address(num, &address); - wait_for_block(electrs, cur_height as usize + num).await; + wait_for_block(bitcoind, electrs, cur_height as usize + num).await; print!(" Done!"); println!("\n"); } @@ -773,27 +773,25 @@ pub(crate) fn invalidate_blocks(bitcoind: &BitcoindClient, num_blocks: usize) { assert!(new_cur_height + num_blocks == cur_height); } -pub(crate) async fn wait_for_block(electrs: &E, min_height: usize) { - let mut header = match electrs.block_headers_subscribe() { - Ok(header) => header, - Err(_) => { - // While subscribing should succeed the first time around, we ran into some cases where - // it didn't. Since we can't proceed without subscribing, we try again after a delay - // and panic if it still fails. - tokio::time::sleep(Duration::from_secs(3)).await; - electrs.block_headers_subscribe().expect("failed to subscribe to block headers") - }, - }; - loop { - if header.height >= min_height { - break; +pub(crate) async fn wait_for_block( + bitcoind: &BitcoindClient, electrs: &E, min_height: usize, +) { + let expected_block_hash = exponential_backoff_poll(|| { + let bitcoind_height = + bitcoind.get_blockchain_info().expect("failed to get blockchain info").blocks as usize; + if bitcoind_height < min_height { + return None; } - header = exponential_backoff_poll(|| { - electrs.ping().expect("failed to ping electrs"); - electrs.block_headers_pop().expect("failed to pop block header") - }) - .await; - } + bitcoind.get_block_hash(min_height as u64).ok()?.block_hash().ok() + }) + .await; + // A height-only wait can return the old header during a same-height reorg. Require the + // replacement hash so callers cannot sync against the stale chain by mistake. + exponential_backoff_poll(|| { + let header = electrs.block_header(min_height).ok()?; + (header.block_hash() == expected_block_hash).then_some(()) + }) + .await; } pub(crate) async fn wait_for_tx(electrs: &E, txid: Txid) { diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index b07a90629f..054b9d7588 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -807,7 +807,7 @@ async fn reorged_onchain_payment_returns_to_unconfirmed() { .call("generateblock", &[json!(replacement_address.to_string()), json!([])]) .expect("failed to generate empty block"); } - wait_for_block(&electrsd.client, original_height as usize + 1).await; + wait_for_block(&bitcoind.client, &electrsd.client, original_height as usize + 1).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); @@ -2056,7 +2056,7 @@ async fn splice_payment_reorged_to_unconfirmed() { .call("generateblock", &[json!(replacement_address.to_string()), json!([])]) .expect("failed to generate empty block"); } - wait_for_block(&electrsd.client, original_height as usize + 1).await; + wait_for_block(&bitcoind.client, &electrsd.client, original_height as usize + 1).await; node_b.sync_wallets().unwrap(); // The funding payment returns to `Unconfirmed` and stays `Pending`, exercising the From 79a97945526b226a70ff55344d596424c9dca0d8 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 15 Jul 2026 12:45:11 +0200 Subject: [PATCH 3/4] Wait for actual funding outpoint spends The spend helper treated any script history as proof of a spend. The funding transaction itself already creates such a history entry, so the helper normally returned before Electrum indexed the closing transaction. A following reorg could therefore begin while the funding outpoint was still unspent. Wait until the exact transaction output leaves Electrum's unspent set. Co-Authored-By: HAL 9000 --- tests/common/mod.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 3e8b60fe43..3eb12a76aa 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -810,15 +810,14 @@ pub(crate) async fn wait_for_outpoint_spend(electrs: &E, outpoin let tx = electrs.transaction_get(&outpoint.txid).unwrap(); let txout_script = tx.output.get(outpoint.vout as usize).unwrap().clone().script_pubkey; - let is_spent = !electrs.script_get_history(&txout_script).unwrap().is_empty(); - if is_spent { - return; - } - + // Script history already contains the funding transaction itself, so wait until the exact + // funding outpoint leaves the unspent set instead of treating any history as a spend. exponential_backoff_poll(|| { electrs.ping().unwrap(); - let is_spent = !electrs.script_get_history(&txout_script).unwrap().is_empty(); + let is_spent = !electrs.script_list_unspent(&txout_script).unwrap().iter().any(|output| { + output.tx_hash == outpoint.txid && output.tx_pos == outpoint.vout as usize + }); is_spent.then_some(()) }) .await; From 87b780e9d968ed642524c79410fefe969036f804 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 15 Jul 2026 12:49:33 +0200 Subject: [PATCH 4/4] Synchronize force-close sweep checks The force-close loop mined separately for each node even though every node shares one chain. Mining for the first node advanced later nodes past the exact intermediate state the test required. Sweep publication and Electrum indexing are also asynchronous, so immediate assertions could observe either valid state. Advance the shared chain once and poll every claimable sweep through broadcast and confirmation. Preserve the force-close counterexample that fails when only the funding-depth fix is applied. Co-Authored-By: HAL 9000 --- tests/reorg_test.proptest-regressions | 1 + tests/reorg_test.rs | 104 +++++++++++++++++--------- 2 files changed, 71 insertions(+), 34 deletions(-) diff --git a/tests/reorg_test.proptest-regressions b/tests/reorg_test.proptest-regressions index eba903fd03..74be29aeb1 100644 --- a/tests/reorg_test.proptest-regressions +++ b/tests/reorg_test.proptest-regressions @@ -1 +1,2 @@ cc 06354c9b049db51c31557bf46d86a68bdd4049577cbd9190fb81c7824b18f0e6 # shrinks to reorg_depth = 6, force_close = false +cc ffba5725835411b0948e834640dd37fc9a35696a301d7f2d8f2054f158bd2cae # shrinks to reorg_depth = 1, force_close = true diff --git a/tests/reorg_test.rs b/tests/reorg_test.rs index 10f1d44511..3a87ca7626 100644 --- a/tests/reorg_test.rs +++ b/tests/reorg_test.rs @@ -8,11 +8,27 @@ use proptest::prelude::prop; use proptest::proptest; use crate::common::{ - expect_event, generate_blocks_and_wait, invalidate_blocks, open_channel, - premine_and_distribute_funds, random_chain_source, random_config, setup_bitcoind_and_electrsd, - setup_node, wait_for_outpoint_spend, + expect_event, exponential_backoff_poll, generate_blocks_and_wait, invalidate_blocks, + open_channel, premine_and_distribute_funds, random_chain_source, random_config, + setup_bitcoind_and_electrsd, setup_node, wait_for_outpoint_spend, wait_for_tx, }; +async fn wait_for_pending_sweep_balance( + node: &ldk_node::Node, mut matches_balance: F, +) -> PendingSweepBalance +where + F: FnMut(&PendingSweepBalance) -> bool, +{ + exponential_backoff_poll(|| { + node.sync_wallets().unwrap(); + node.list_balances() + .pending_balances_from_channel_closures + .into_iter() + .find(|balance| matches_balance(balance)) + }) + .await +} + proptest! { #![proptest_config(proptest::test_runner::Config::with_cases(5))] #[test] @@ -146,40 +162,60 @@ proptest! { sync_wallets!(); if force_close { - for node in &nodes { - node.sync_wallets().unwrap(); - // If there is no more balance, there is nothing to process here. - if node.list_balances().lightning_balances.len() < 1 { - return; - } - match node.list_balances().lightning_balances[0] { - LightningBalance::ClaimableAwaitingConfirmations { - confirmation_height, - .. - } => { - let cur_height = node.status().current_best_block.height; - let blocks_to_go = confirmation_height - cur_height; - generate_blocks_and_wait(bitcoind, electrs, blocks_to_go as usize).await; - node.sync_wallets().unwrap(); - }, - _ => panic!("Unexpected balance state for node_hub!"), - } + let claimable_nodes = nodes + .iter() + .filter_map(|node| { + node.list_balances().lightning_balances.iter().find_map(|balance| { + match balance { + LightningBalance::ClaimableAwaitingConfirmations { + confirmation_height, + .. + } => Some((node, *confirmation_height)), + _ => None, + } + }) + }) + .collect::>(); + let confirmation_height = claimable_nodes + .iter() + .map(|(_, confirmation_height)| *confirmation_height) + .max() + .expect("Missing claimable force-close balance"); + let cur_height = nodes[0].status().current_best_block.height; + let blocks_to_go = confirmation_height.saturating_sub(cur_height); + if blocks_to_go > 0 { + generate_blocks_and_wait(bitcoind, electrs, blocks_to_go as usize).await; + sync_wallets!(); + } - assert!(node.list_balances().lightning_balances.len() < 2); - assert!(node.list_balances().pending_balances_from_channel_closures.len() > 0); - match node.list_balances().pending_balances_from_channel_closures[0] { - PendingSweepBalance::BroadcastAwaitingConfirmation { .. } => {}, - _ => panic!("Unexpected balance state!"), + // Mining for one node advances the shared chain for every node. Mature all + // claimable outputs together, wait for every sweep to reach the mempool, then + // confirm them and wait for `AwaitingThresholdConfirmations`. + for (node, _) in &claimable_nodes { + let pending_balance = wait_for_pending_sweep_balance(node, |balance| { + matches!( + balance, + PendingSweepBalance::BroadcastAwaitingConfirmation { .. } + | PendingSweepBalance::AwaitingThresholdConfirmations { .. } + ) + }) + .await; + if let PendingSweepBalance::BroadcastAwaitingConfirmation { + latest_spending_txid, + .. + } = pending_balance + { + wait_for_tx(electrs, latest_spending_txid).await; } + } - generate_blocks_and_wait(&bitcoind, electrs, 1).await; - node.sync_wallets().unwrap(); - assert!(node.list_balances().lightning_balances.len() < 2); - assert!(node.list_balances().pending_balances_from_channel_closures.len() > 0); - match node.list_balances().pending_balances_from_channel_closures[0] { - PendingSweepBalance::AwaitingThresholdConfirmations { .. } => {}, - _ => panic!("Unexpected balance state!"), - } + generate_blocks_and_wait(bitcoind, electrs, 1).await; + sync_wallets!(); + for (node, _) in &claimable_nodes { + wait_for_pending_sweep_balance(node, |balance| { + matches!(balance, PendingSweepBalance::AwaitingThresholdConfirmations { .. }) + }) + .await; } }