Repository navigation
Startup sync can re-confirm a transaction without first unconfirming it #1076
Description
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 --nocaptureActual 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.
Activity
randomlogin commentedon Oct 12, 2026
@joostjager Why was that closed?
I'm encountering a similar problem, going to make a fix if it wasn't made yet.
tnull commentedon Oct 13, 2026
This is related/essentially a duplicate of #29, which we erroneously closed as we thought it was addressed. Reopened for now, will have a fix up shortly.
Reconcile restored monitors from their own tips
Reconcile restored monitors from their own tips
Reconcile restored monitors from their own tips