This was discovered while developing force-close fuzzing. This report is AI-generated and may contain mistakes.
Summary
During splice negotiation, an initiator can reach FundingTransactionReadyForSigning while the acceptor has already sent its initial commitment_signed. If the initiator then locally aborts the splice with cancel_funding_contributed before that commitment_signed is delivered, receiving the stale in-flight commitment_signed currently force-closes the channel with:
Invalid commitment tx signature from peer
The stale commitment_signed belongs to the splice negotiation that was just aborted. It should be ignored or otherwise treated as stale splice state, not validated against the post-abort live-channel state.
Impact
This can turn a valid local splice abort plus normal message reordering into a channel force-close. The peer did not necessarily send a malformed signature. Instead, the signature appears invalid because it is checked against the wrong channel state after the splice abort.
Why this is an LDK splice/state-machine issue
The reproducer does not depend on force-close fuzzing behavior. It uses normal splice APIs and normal message delivery:
- Start a splice-out where only the initiator contributes funds.
- Complete the splice interactive transaction negotiation.
- Observe the initiator's
FundingTransactionReadyForSigning.
- Hold the acceptor's already-generated initial
commitment_signed in flight.
- Locally abort the splice with
cancel_funding_contributed.
- Keep the outbound
tx_abort queued.
- Deliver the stale acceptor
commitment_signed to the initiator.
This is a plausible transport ordering: the acceptor's response can already be in flight while the initiator's local caller chooses to cancel, and the initiator's later tx_abort may not reach the acceptor first.
The force-close fuzz harness found this ordering, but the root behavior is in the splice state machine: after local abort, LDK still treats the old splice commitment_signed as a live commitment update and validates it against the wrong state.
Expected behavior
After local splice abort, a stale in-flight commitment_signed for the aborted splice should not force-close the channel. It should be ignored, treated as stale, or otherwise handled as part of the aborted splice negotiation.
Actual behavior
LDK validates the stale commitment_signed after the local splice abort, emits an error message containing Invalid commitment tx signature from peer, broadcasts a disabled channel update, and surfaces:
ChannelClosed {
reason: ProcessingError { err: "Invalid commitment tx signature from peer" },
...
}
Focused regression test
The following unit test captures the desired behavior:
#[test]
fn cancel_funding_contributed_then_inflight_commitment_signed_does_not_close_channel() {
let chanmon_cfgs = create_chanmon_cfgs(2);
let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]);
let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
let initiator = &nodes[0];
let acceptor = &nodes[1];
let node_id_initiator = initiator.node.get_our_node_id();
let node_id_acceptor = acceptor.node.get_our_node_id();
let initial_channel_capacity = 100_000;
let (_, _, channel_id, _) =
create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_capacity, 0);
let outputs = vec![TxOut {
value: Amount::from_sat(1_000),
script_pubkey: initiator.wallet_source.get_change_script().unwrap(),
}];
let funding_contribution =
initiate_splice_out(initiator, acceptor, channel_id, outputs).unwrap();
let new_funding_script = complete_splice_handshake(initiator, acceptor);
complete_interactive_funding_negotiation(
initiator,
acceptor,
channel_id,
funding_contribution.clone(),
new_funding_script,
);
// Both peers completed the interactive transaction exchange. Since only the
// initiator contributed splice funds, the initiator must still surface the
// unsigned funding transaction before it may send its initial
// `commitment_signed`.
let _ = get_event!(initiator, Event::FundingTransactionReadyForSigning);
assert!(acceptor.node.get_and_clear_pending_events().is_empty());
assert!(initiator.node.get_and_clear_pending_msg_events().is_empty());
// The acceptor has no funding contribution, so it can send its initial
// `commitment_signed` immediately. Hold that message to model it racing with
// the local caller's decision to cancel instead of sign.
let acceptor_commit_sig = get_htlc_update_msgs(acceptor, &node_id_initiator);
assert_eq!(acceptor_commit_sig.commitment_signed.len(), 1);
// Cancel before signing. This is a valid API flow: local contribution is
// discarded, the splice negotiation fails locally, and LDK queues a
// `tx_abort` for the peer.
initiator.node.cancel_funding_contributed(&channel_id, &node_id_acceptor).unwrap();
let reason = NegotiationFailureReason::LocallyCanceled;
expect_splice_failed_events(initiator, &channel_id, funding_contribution, reason);
// Keep our `tx_abort` queued. The fuzz failure has this exact ordering: our
// abort is outbound, but the acceptor's earlier `commitment_signed` reaches
// us first.
let tx_abort = get_event_msg!(initiator, MessageSendEvent::SendTxAbort, node_id_acceptor);
assert_eq!(tx_abort.channel_id, channel_id);
initiator
.node
.handle_commitment_signed(node_id_acceptor, &acceptor_commit_sig.commitment_signed[0]);
// The delayed `commitment_signed` belonged to the splice we just aborted. It
// should not be validated against the post-abort channel state and should
// not force-close the live channel as an invalid commitment signature.
let msg_events = initiator.node.get_and_clear_pending_msg_events();
let got_invalid_sig_error = msg_events.iter().any(|event| {
matches!(
event,
MessageSendEvent::HandleError {
action: msgs::ErrorAction::SendErrorMessage { msg },
..
} if msg.data.contains("Invalid commitment tx signature from peer")
)
});
let events = initiator.node.get_and_clear_pending_events();
let got_invalid_sig_close = events.iter().any(|event| {
matches!(
event,
Event::ChannelClosed {
reason: ClosureReason::ProcessingError { err },
..
} if err == "Invalid commitment tx signature from peer"
)
});
let got_channel_close = events.iter().any(|event| matches!(event, Event::ChannelClosed { .. }));
assert!(
!got_invalid_sig_error,
"stale commitment_signed generated invalid-signature error: {msg_events:?}; events: {events:?}"
);
assert!(
!got_invalid_sig_close,
"stale commitment_signed force-closed with invalid-signature processing error: {events:?}"
);
assert!(!got_channel_close, "stale commitment_signed should not close the channel: {events:?}");
}
This was discovered while developing force-close fuzzing. This report is AI-generated and may contain mistakes.
Summary
During splice negotiation, an initiator can reach
FundingTransactionReadyForSigningwhile the acceptor has already sent its initialcommitment_signed. If the initiator then locally aborts the splice withcancel_funding_contributedbefore thatcommitment_signedis delivered, receiving the stale in-flightcommitment_signedcurrently force-closes the channel with:The stale
commitment_signedbelongs to the splice negotiation that was just aborted. It should be ignored or otherwise treated as stale splice state, not validated against the post-abort live-channel state.Impact
This can turn a valid local splice abort plus normal message reordering into a channel force-close. The peer did not necessarily send a malformed signature. Instead, the signature appears invalid because it is checked against the wrong channel state after the splice abort.
Why this is an LDK splice/state-machine issue
The reproducer does not depend on force-close fuzzing behavior. It uses normal splice APIs and normal message delivery:
FundingTransactionReadyForSigning.commitment_signedin flight.cancel_funding_contributed.tx_abortqueued.commitment_signedto the initiator.This is a plausible transport ordering: the acceptor's response can already be in flight while the initiator's local caller chooses to cancel, and the initiator's later
tx_abortmay not reach the acceptor first.The force-close fuzz harness found this ordering, but the root behavior is in the splice state machine: after local abort, LDK still treats the old splice
commitment_signedas a live commitment update and validates it against the wrong state.Expected behavior
After local splice abort, a stale in-flight
commitment_signedfor the aborted splice should not force-close the channel. It should be ignored, treated as stale, or otherwise handled as part of the aborted splice negotiation.Actual behavior
LDK validates the stale
commitment_signedafter the local splice abort, emits an error message containingInvalid commitment tx signature from peer, broadcasts a disabled channel update, and surfaces:Focused regression test
The following unit test captures the desired behavior: