Surfaced during chaos testing directly on ldk-server. To be verified.
Environment
- ldk-node upstream/main:
b812128c51c0171f510d81847b3ed18f0c34a294 (2026-08-31)
- Pinned Rust Lightning revision:
9174965af9437196c527a9aa0df36bbcf050c8bb
Problem
BitcoindChainSource::continuously_sync_wallets restores all ChannelMonitors into one aggregate ChainMonitor, then supplies only the lowest restored ChannelMonitor::current_best_block() as that aggregate listener's checkpoint to synchronize_listeners.
That is not sufficient when restored monitors have different tips after a reorganization. If monitor A recorded a commitment transaction in old block H + 1 while monitor B remains at H, startup supplies B's tip to the aggregate listener. synchronize_listeners then connects a replacement block at H + 1 directly to every monitor. If that replacement block includes the same commitment transaction, monitor A receives a new confirmation without first receiving blocks_disconnected and transaction_unconfirmed.
Minimal reproduction
Add the following to the src/chain/bitcoind.rs test module. It uses real zero-conf channel monitors, a real counterparty force-close transaction, and the same aggregate listener selection currently used by startup.
use std::future::Future;
use bitcoin::block::{Block, Header, Version};
use bitcoin::constants::genesis_block;
use bitcoin::hash_types::{BlockHash, TxMerkleNode};
use bitcoin::hashes::Hash;
use bitcoin::{
absolute::LockTime, Amount, Network, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut,
Witness,
};
use lightning::chain::{BlockLocator, Listen};
use lightning::ln::functional_test_utils::{
create_channel_manual_funding, create_chanmon_cfgs, create_network, create_node_cfgs,
create_node_chanmgrs, test_legacy_channel_config,
};
use lightning::util::test_utils::TestChainMonitor;
use lightning_block_sync::init::synchronize_listeners;
use lightning_block_sync::{BlockData, BlockHeaderData, BlockSource, BlockSourceError};
struct TestBlockSource {
blocks: Vec<Block>,
}
impl TestBlockSource {
fn block_header_data(&self, height: usize) -> BlockHeaderData {
let mut chainwork = self.blocks[0].header.work();
for block in self.blocks.iter().take(height + 1).skip(1) {
chainwork = chainwork + block.header.work();
}
BlockHeaderData { header: self.blocks[height].header, height: height as u32, chainwork }
}
}
impl BlockSource for TestBlockSource {
fn get_header<'a>(
&'a self, header_hash: &'a BlockHash, _height_hint: Option<u32>,
) -> impl Future<Output = Result<BlockHeaderData, BlockSourceError>> + Send + 'a {
async move {
self.blocks
.iter()
.enumerate()
.find(|(_, block)| block.block_hash() == *header_hash)
.map(|(height, _)| self.block_header_data(height))
.ok_or_else(|| BlockSourceError::transient("header not found"))
}
}
fn get_block<'a>(
&'a self, header_hash: &'a BlockHash,
) -> impl Future<Output = Result<BlockData, BlockSourceError>> + Send + 'a {
async move {
self.blocks
.iter()
.find(|block| block.block_hash() == *header_hash)
.cloned()
.map(BlockData::FullBlock)
.ok_or_else(|| BlockSourceError::transient("block not found"))
}
}
fn get_best_block<'a>(
&'a self,
) -> impl Future<Output = Result<(BlockHash, Option<u32>), BlockSourceError>> + Send + 'a {
async move {
let height = self.blocks.len().checked_sub(1).expect("test chain has a genesis block");
Ok((self.blocks[height].block_hash(), Some(height as u32)))
}
}
}
fn mine_block(prev_blockhash: BlockHash, time: u32, transactions: Vec<Transaction>) -> Block {
let mut coinbase_witness = Witness::new();
let witness_reserved_value = [0; 32];
coinbase_witness.push(witness_reserved_value);
let coinbase = Transaction {
version: bitcoin::transaction::Version::TWO,
lock_time: LockTime::ZERO,
input: vec![TxIn {
previous_output: OutPoint::null(),
script_sig: ScriptBuf::new(),
sequence: Sequence::MAX,
witness: coinbase_witness,
}],
output: Vec::new(),
};
let mut txdata = vec![coinbase];
txdata.extend(transactions);
let mut block = Block {
header: Header {
version: Version::NO_SOFT_FORK_SIGNALLING,
prev_blockhash,
merkle_root: TxMerkleNode::all_zeros(),
time,
bits: bitcoin::Target::from_be_bytes([0xff; 32]).to_compact_lossy(),
nonce: 0,
},
txdata,
};
let witness_root = block.witness_root().expect("test block has transactions");
let witness_commitment = Block::compute_witness_commitment(&witness_root, &witness_reserved_value);
let mut witness_commitment_script = vec![0x6a, 0x24, 0xaa, 0x21, 0xa9, 0xed];
witness_commitment_script.extend(witness_commitment.to_byte_array());
block.txdata[0].output.push(TxOut {
value: Amount::ZERO,
script_pubkey: ScriptBuf::from_bytes(witness_commitment_script),
});
block.header.merkle_root = block.compute_merkle_root().expect("test block has a transaction");
while block.header.validate_pow(block.header.target()).is_err() {
block.header.nonce = block.header.nonce.wrapping_add(1);
}
block
}
#[tokio::test]
#[should_panic(expected = "was already confirmed and is being re-confirmed in a different block")]
async fn initial_sync_reconfirms_transaction_in_heterogeneous_channel_monitors() {
let chanmon_cfgs = create_chanmon_cfgs(3);
let node_cfgs = create_node_cfgs(3, &chanmon_cfgs);
let legacy_config = test_legacy_channel_config();
let node_chanmgrs = create_node_chanmgrs(
3,
&node_cfgs,
&[Some(legacy_config.clone()), Some(legacy_config.clone()), Some(legacy_config)],
);
let nodes = create_network(3, &node_cfgs, &node_chanmgrs);
let (channel_id_a, _, _) =
create_channel_manual_funding(&nodes, 0, 1, 100_000, 10_001, true);
let (channel_id_b, _, _) =
create_channel_manual_funding(&nodes, 0, 2, 100_000, 10_001, true);
let node_0_id = nodes[0].node.get_our_node_id();
nodes[1]
.node
.force_close_broadcasting_latest_txn(&channel_id_a, &node_0_id, "test".to_owned())
.unwrap();
let mut broadcasts = nodes[1].tx_broadcaster.txn_broadcast();
assert_eq!(broadcasts.len(), 1);
let commitment_tx = broadcasts.pop().unwrap();
let monitor_at_fork = nodes[0].chain_monitor.chain_monitor.remove_monitor(&channel_id_b);
let old_block = mine_block(nodes[0].best_block_hash(), 1, vec![commitment_tx.clone()]);
nodes[0].chain_monitor.chain_monitor.block_connected(&old_block, 1);
let monitor_with_old_confirmation =
nodes[0].chain_monitor.chain_monitor.remove_monitor(&channel_id_a);
let restarted_chain_monitor = TestChainMonitor::new(
Some(&chanmon_cfgs[0].chain_source),
&chanmon_cfgs[0].tx_broadcaster,
&chanmon_cfgs[0].logger,
&chanmon_cfgs[0].fee_estimator,
&chanmon_cfgs[0].persister,
&chanmon_cfgs[0].keys_manager,
);
restarted_chain_monitor
.load_existing_monitor(channel_id_a, monitor_with_old_confirmation)
.unwrap();
restarted_chain_monitor.load_existing_monitor(channel_id_b, monitor_at_fork).unwrap();
let worst_monitor_best_block = restarted_chain_monitor
.chain_monitor
.list_monitors()
.iter()
.flat_map(|channel_id| restarted_chain_monitor.chain_monitor.get_monitor(*channel_id))
.map(|monitor| monitor.current_best_block())
.min_by_key(|best_block| best_block.height)
.unwrap();
assert_eq!(worst_monitor_best_block, BlockLocator::from_network(Network::Testnet));
let replacement_block =
mine_block(genesis_block(Network::Testnet).block_hash(), 2, vec![commitment_tx]);
let chain = TestBlockSource { blocks: vec![genesis_block(Network::Testnet), replacement_block] };
synchronize_listeners(
&chain,
Network::Testnet,
vec![(
worst_monitor_best_block,
&restarted_chain_monitor.chain_monitor as &(dyn Listen + Send + Sync),
)],
)
.await
.unwrap();
}
Run it with:
cargo test --lib chain::bitcoind::tests::initial_sync_reconfirms_transaction_in_heterogeneous_channel_monitors -- --exact --nocapture
Actual behavior
The test deterministically reaches the Rust Lightning assertion:
Transaction <txid> was already confirmed and is being re-confirmed in a different block.
This indicates a severe bug in the transaction connection logic - a reorg should have been processed first!
It is marked #[should_panic], so the focused command reports success only after observing that assertion. On the upstream/main revision above, the final result is:
running 1 test
test chain::bitcoind::tests::initial_sync_reconfirms_transaction_in_heterogeneous_channel_monitors - should panic ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 165 filtered out
Expected behavior
Before a monitor sees a transaction in a replacement block, startup synchronization must ensure that every monitor which saw that transaction in a disconnected block receives the corresponding reorg notification. Passing the minimum restored monitor height to the aggregate ChainMonitor is insufficient when monitors have different block hashes or heights.
The relevant selection is in src/chain/bitcoind.rs, in BitcoindChainSource::continuously_sync_wallets, immediately before synchronize_listeners.
Duplicate check
Open issues in lightningdevkit/ldk-node were searched with chain monitor, startup synchronization, reorg, duplicate confirmation, and already confirmed.
- #381, returned for
chain monitor, tracks persistence-failure panics. It does not cover reorg startup synchronization or heterogeneous monitor tips.
- #1044, returned for
reorg, tracks funding-payment classification consistency. It does not cover chain-monitor callback ordering.
- The other exact queries returned no open issues.
No duplicate was found.
Surfaced during chaos testing directly on
ldk-server. To be verified.Environment
b812128c51c0171f510d81847b3ed18f0c34a294(2026-08-31)9174965af9437196c527a9aa0df36bbcf050c8bbProblem
BitcoindChainSource::continuously_sync_walletsrestores allChannelMonitors into one aggregateChainMonitor, then supplies only the lowest restoredChannelMonitor::current_best_block()as that aggregate listener's checkpoint tosynchronize_listeners.That is not sufficient when restored monitors have different tips after a reorganization. If monitor A recorded a commitment transaction in old block
H + 1while monitor B remains atH, startup supplies B's tip to the aggregate listener.synchronize_listenersthen connects a replacement block atH + 1directly to every monitor. If that replacement block includes the same commitment transaction, monitor A receives a new confirmation without first receivingblocks_disconnectedandtransaction_unconfirmed.Minimal reproduction
Add the following to the
src/chain/bitcoind.rstest module. It uses real zero-conf channel monitors, a real counterparty force-close transaction, and the same aggregate listener selection currently used by startup.Run it with:
cargo test --lib chain::bitcoind::tests::initial_sync_reconfirms_transaction_in_heterogeneous_channel_monitors -- --exact --nocaptureActual behavior
The test deterministically reaches the Rust Lightning assertion:
It is marked
#[should_panic], so the focused command reports success only after observing that assertion. On the upstream/main revision above, the final result is:Expected behavior
Before a monitor sees a transaction in a replacement block, startup synchronization must ensure that every monitor which saw that transaction in a disconnected block receives the corresponding reorg notification. Passing the minimum restored monitor height to the aggregate
ChainMonitoris insufficient when monitors have different block hashes or heights.The relevant selection is in
src/chain/bitcoind.rs, inBitcoindChainSource::continuously_sync_wallets, immediately beforesynchronize_listeners.Duplicate check
Open issues in
lightningdevkit/ldk-nodewere searched withchain monitor,startup synchronization,reorg,duplicate confirmation, andalready confirmed.chain monitor, tracks persistence-failure panics. It does not cover reorg startup synchronization or heterogeneous monitor tips.reorg, tracks funding-payment classification consistency. It does not cover chain-monitor callback ordering.No duplicate was found.