Skip to content

Startup sync can re-confirm a transaction without first unconfirming it #1076

Description

@joostjager

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions