Merge PR 'Drop stale splice signature on disconnect' (#4954)
What changed, and why it matters
This change fixes a Lightning channel splicing bug: when two peers temporarily disconnect during a splice, any half-finished signature the other side already sent is now discarded. Before the fix, that stale signature could be reused after reconnecting, which might let a peer apply an old splice state or confuse the channel re-establishment logic. The patch is small and adds a test showing the signature is dropped and no monitor update occurs at that point.
Treat as a low-to-moderate correctness/security fix. Users running nodes that support splicing should upgrade to a release containing this commit. No immediate emergency response is indicated, but operators should monitor for any splice-related channel force-closes or inconsistencies.
Security signals we found
State-invalidation bug in multi-step protocol (splice negotiation)
Stale cryptographic signature not cleared on disconnect
Potential reuse of old commitment state after reconnect
Test added to verify signature is dropped and no premature monitor update occurs
Evidence from the diff
In lightning/src/ln/channel.rs, when handling a peer disconnect while a splice is in FundingNegotiation::AwaitingSignatures, the code now calls .take() on initial_commitment_signed_from_counterparty, clearing any buffered counterparty commitment_signed received before disconnect. A new splicing test in splicing_tests.rs buffers the acceptor’s initial commitment_signed, disconnects, and asserts no monitor updates are added until the funding transaction is later signed. This prevents a stale splice signature from surviving a disconnect/reconnect cycle.
Changed components
lightning/src/ln/channel.rslightning/src/ln/splicing_tests.rsLDK splicing / channel re-establishment logicInspect captured patch +22 / −1
### lightning/src/ln/channel.rs
@@ -1772,6 +1772,16 @@ where
chan.exit_quiescence();
None
} else {
+ if let Some(FundingNegotiation::AwaitingSignatures {
+ initial_commitment_signed_from_counterparty,
+ ..
+ }) = chan
+ .pending_splice
+ .as_mut()
+ .and_then(|pending_splice| pending_splice.funding_negotiation.as_mut())
+ {
+ initial_commitment_signed_from_counterparty.take();
+ }
None
}
} else {
### lightning/src/ln/splicing_tests.rs
@@ -2770,7 +2770,17 @@ fn do_test_splice_reestablish(reload: bool, async_monitor_update: bool) {
assert!(nodes[1].node.get_and_clear_pending_events().is_empty());
assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty());
- let _ = get_htlc_update_msgs(&nodes[1], &node_id_0);
+ let acceptor_commitment_signed = get_htlc_update_msgs(&nodes[1], &node_id_0);
+ if !reload {
+ // Buffer the peer's initial `commitment_signed` before disconnecting. Since we haven't
+ // signed yet, it will be stashed until we do so, though it can be dropped if a disconnect
+ // happens.
+ nodes[0].node.handle_commitment_signed_batch_test(
+ node_id_1,
+ &acceptor_commitment_signed.commitment_signed,
+ );
+ check_added_monitors(&nodes[0], 0);
+ }
// Disconnect them, and handle the signing event on the initiator side.
if reload {
@@ -2830,6 +2840,7 @@ fn do_test_splice_reestablish(reload: bool, async_monitor_update: bool) {
let tx = nodes[0].wallet_source.sign_tx(unsigned_transaction).unwrap();
nodes[0].node.funding_transaction_signed(&channel_id, &node_id_1, tx).unwrap();
}
+ check_added_monitors(&nodes[0], 0);
// Since they're not connected, no messages should be sent.
assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty());Why this scored 42/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.