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:
- A-B starts a splice.
- B's async signer path delays B's splice
tx_signatures until the splice monitor update completes.
- B sends
tx_signatures to A.
- A handles them, signs and broadcasts the splice transaction, and the splice transaction confirms.
- A's reciprocal
tx_signatures to B is delayed in the network or harness.
- Separately, C claims a forwarded payment, causing B to persist the payment preimage on the same A-B channel.
- 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);
}
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_signaturesmessage is delivered while the receiving channel has an unrelated monitor update in progress.The assertion is:
On the current reproducer branch this is reached from
handle_tx_signaturesinlightning/src/ln/channel.rs.Affected behavior
The problematic sequence is:
tx_signaturesuntil the splice monitor update completes.tx_signaturesto A.tx_signaturesto B is delayed in the network or harness.tx_signatureswhile 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_signaturesmessage 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:
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_signaturesshould 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_signaturesreaches a pending-monitor state wheremonitor_pending_tx_signaturesis false.The focused test currently fails with:
The exact line number may move as the branch changes.
Focused unit test
Current cleaned-up reproducer: