Skip to content

Splice tx_signatures assertion with unrelated monitor update pending #4729

@joostjager

Description

@joostjager

This was discovered while developing force-close fuzzing. This report is AI-generated and may
contain mistakes.

Problem

A splice can hit an internal assertion when a delayed tx_signatures message is delivered while the receiving channel has an unrelated monitor update in progress.

The assertion is:

assertion failed: self.context.monitor_pending_tx_signatures

On the current reproducer branch this is reached from handle_tx_signatures in lightning/src/ln/channel.rs.

Affected behavior

The problematic sequence is:

  1. A-B starts a splice.
  2. B's async signer path delays B's splice tx_signatures until the splice monitor update completes.
  3. B sends tx_signatures to A.
  4. A handles them, signs and broadcasts the splice transaction, and the splice transaction confirms.
  5. A's reciprocal tx_signatures to B is delayed in the network or harness.
  6. Separately, C claims a forwarded payment, causing B to persist the payment preimage on the same A-B channel.
  7. B receives A's delayed splice tx_signatures while that unrelated A-B preimage monitor update is still in progress.

The pending monitor update in step 7 is not the splice tx-signatures monitor update. It is a payment preimage update for settling an HTLC backward over A-B.

Impact

This appears to be a valid message and persistence interleaving. A peer that already received enough signatures may broadcast the splice transaction before its reciprocal tx_signatures message reaches the other peer. Meanwhile, normal HTLC settlement on the same channel may require a different monitor update.

The result is an assertion rather than graceful handling of the delayed splice message.

Why this looks like an LDK splice/state-machine issue

This was initially found through chanmon consistency fuzzing, but it does not require force-close fuzzing behavior to reproduce. A focused unit test using the existing async signer, splice, payment, and monitor-update helpers reproduces the same assertion without any force close.

The force-close fuzz harness is useful because it explores unusual interleavings, but the minimized behavior here is a splice message ordering combined with normal forwarded-payment settlement. The reproducer only relies on:

  • async signer blocking and unblocking,
  • interactive splice signing,
  • chain confirmation of the splice transaction,
  • an unrelated in-flight monitor update for an HTLC preimage,
  • delayed delivery of tx_signatures.

That points to the splice/channel state machine conflating "some monitor update is pending" with "the tx-signatures monitor update is pending".

Expected behavior

Receiving delayed splice tx_signatures should not assert merely because the channel currently has an unrelated monitor update in progress.

Possible acceptable outcomes may include deferring processing, treating the message as stale if the splice is already confirmed, or otherwise handling it according to the splice state machine. In any case, the unrelated HTLC-preimage monitor update should not be mistaken for the splice tx-signatures monitor update.

Actual behavior

LDK asserts because handle_tx_signatures reaches a pending-monitor state where monitor_pending_tx_signatures is false.

The focused test currently fails with:

thread 'ln::async_signer_tests::test_async_splice_receives_tx_signatures_while_unrelated_monitor_update_pending' panicked at lightning/src/ln/channel.rs:9673:13:
assertion failed: self.context.monitor_pending_tx_signatures

The exact line number may move as the branch changes.

Focused unit test

Current cleaned-up reproducer:

#[test]
fn test_async_splice_receives_tx_signatures_while_unrelated_monitor_update_pending() {
	let chanmon_cfgs = create_chanmon_cfgs(3);
	let node_cfgs = create_node_cfgs(3, &chanmon_cfgs);
	let node_chanmgrs = create_node_chanmgrs(3, &node_cfgs, &[None, None, None]);
	let nodes = create_network(3, &node_cfgs, &node_chanmgrs);

	let channel_id = create_announced_chan_between_nodes(&nodes, 0, 1).2;
	create_announced_chan_between_nodes(&nodes, 1, 2);

	let (initiator, acceptor) = (&nodes[0], &nodes[1]);
	let initiator_node_id = initiator.node.get_our_node_id();
	let acceptor_node_id = acceptor.node.get_our_node_id();
	let final_node_id = nodes[2].node.get_our_node_id();

	// Leave a forwarded HTLC across A-B and B-C. Later, C will reveal the
	// preimage so B has to persist an unrelated preimage update on A-B while the
	// delayed splice `tx_signatures` are still in flight.
	let (payment_preimage, payment_hash, ..) =
		route_payment(initiator, &[acceptor, &nodes[2]], 1_000_000);

	// Keep the A-B splice from completing immediately at B. The disabled
	// counterparty-commitment signer forces B to wait for both the signer and
	// the splice monitor update before it can send `tx_signatures`.
	acceptor.disable_channel_signer_op(
		&initiator_node_id,
		&channel_id,
		SignerOp::SignCounterpartyCommitment,
	);

	let outputs = vec![TxOut {
		value: Amount::from_sat(1_000),
		script_pubkey: initiator.wallet_source.get_change_script().unwrap(),
	}];
	let contribution = initiate_splice_out(initiator, acceptor, channel_id, outputs).unwrap();
	negotiate_splice_tx(initiator, acceptor, channel_id, contribution);

	let event = get_event!(initiator, Event::FundingTransactionReadyForSigning);
	if let Event::FundingTransactionReadyForSigning { unsigned_transaction, .. } = event {
		let partially_signed_tx = initiator.wallet_source.sign_tx(unsigned_transaction).unwrap();
		initiator
			.node
			.funding_transaction_signed(&channel_id, &acceptor_node_id, partially_signed_tx)
			.unwrap();
	}

	let initiator_commit_sig = get_htlc_update_msgs(initiator, &acceptor_node_id);

	// B accepts A's splice commitment, but the monitor update remains pending.
	// This is the async-signing window that normally guards emission of B's
	// `tx_signatures`.
	chanmon_cfgs[1].persister.set_update_ret(ChannelMonitorUpdateStatus::InProgress);
	acceptor
		.node
		.handle_commitment_signed(initiator_node_id, &initiator_commit_sig.commitment_signed[0]);
	check_added_monitors(acceptor, 1);
	assert!(acceptor.node.get_and_clear_pending_msg_events().is_empty());

	// Unblock only the signer side first. B can now produce its splice
	// `commitment_signed`, but still must not send `tx_signatures` until the
	// monitor update above completes.
	acceptor.enable_channel_signer_op(
		&initiator_node_id,
		&channel_id,
		SignerOp::SignCounterpartyCommitment,
	);
	acceptor.node.signer_unblocked(None);

	let msg_events = acceptor.node.get_and_clear_pending_msg_events();
	assert_eq!(msg_events.len(), 1, "{msg_events:?}");
	if let MessageSendEvent::UpdateHTLCs { updates, .. } = &msg_events[0] {
		initiator.node.handle_commitment_signed(acceptor_node_id, &updates.commitment_signed[0]);
		check_added_monitors(initiator, 1);
	} else {
		panic!("Unexpected event");
	}

	// Completing B's splice monitor update releases B's `tx_signatures`. This
	// is the update for which `monitor_pending_tx_signatures` is expected to be
	// set.
	acceptor.chain_monitor.complete_sole_pending_chan_update(&channel_id);

	let acceptor_tx_signatures =
		get_event_msg!(acceptor, MessageSendEvent::SendTxSignatures, initiator_node_id);
	initiator.node.handle_tx_signatures(acceptor_node_id, &acceptor_tx_signatures);

	// A can now fully sign and broadcast the splice transaction. Save A's
	// reciprocal `tx_signatures` instead of delivering them to B, so B later
	// sees an old splice message after another monitor update has started.
	let delayed_initiator_tx_signatures =
		get_event_msg!(initiator, MessageSendEvent::SendTxSignatures, acceptor_node_id);
	let mut broadcasted = initiator.tx_broadcaster.txn_broadcast();
	assert_eq!(broadcasted.len(), 1, "{broadcasted:?}");
	let splice_tx = broadcasted.pop().unwrap();

	// Confirm the splice on both A and B before B receives A's delayed
	// `tx_signatures`. This mirrors the fuzz timeline where one side's
	// broadcast can reach chain before the reciprocal message reaches its peer.
	mine_transaction(initiator, &splice_tx);
	mine_transaction(acceptor, &splice_tx);
	let _ = get_event!(initiator, Event::SpliceNegotiated);

	// Claiming the forwarded payment at C creates an HTLC fulfill that B must
	// propagate backward over the same A-B channel that is being spliced.
	nodes[2].node.claim_funds(payment_preimage);
	check_added_monitors(&nodes[2], 1);
	expect_payment_claimed!(nodes[2], payment_hash, 1_000_000);

	let mut commitment_update = get_htlc_update_msgs(&nodes[2], &acceptor_node_id);
	assert_eq!(commitment_update.update_fulfill_htlcs.len(), 1);
	// Deliver only the fulfill to B and make B's A-B monitor update stay
	// in-flight. This monitor update is unrelated to splice tx signatures: it
	// durably records the payment preimage so B can safely settle the incoming
	// HTLC from A.
	chanmon_cfgs[1].persister.set_update_ret(ChannelMonitorUpdateStatus::InProgress);
	acceptor.node.handle_update_fulfill_htlc(
		final_node_id,
		commitment_update.update_fulfill_htlcs.remove(0),
	);
	check_added_monitors(acceptor, 1);
	assert!(acceptor.node.get_and_clear_pending_msg_events().is_empty());

	// Finally deliver A's delayed splice `tx_signatures` while B is waiting on
	// the unrelated HTLC-preimage monitor update. The current implementation
	// asserts because it treats any pending monitor update here as though it
	// must be the earlier splice tx-signatures update.
	acceptor.node.handle_tx_signatures(initiator_node_id, &delayed_initiator_tx_signatures);
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions