Ignore stale announcement_signatures instead of force-closing
What changed, and why it matters
This change fixes a bug where a Lightning node would unnecessarily force-close a payment channel if its peer sent an outdated 'announcement_signatures' message referencing the old channel identifier after a splice (a channel funding update). The node now simply ignores these stale messages, keeping the channel open. The bug was not a direct theft of funds, but it could cause unwanted channel closures and disruption.
Review and merge the patch. It is a defensive fix that improves channel availability without weakening signature validation for messages targeting the current short_channel_id. Monitor for any related edge cases where ignoring might mask genuine misbehavior.
Security signals we found
Force-close avoidance on stale but spec-compliant peer message
Splicing handoff edge case in BOLT #7 announcement signature handling
Channel availability / DoS mitigation
Regression test added for stale announcement_signatures after splice lock
Evidence from the diff
In rust-lightning, the handle_announcement_signatures function in lightning/src/ln/channel.rs previously verified all incoming announcement_signatures against the current UnsignedChannelAnnouncement derived from self.funding. After a splice promotes a new short_channel_id, a peer’s pre-splice announcement_signatures will fail the hash check and previously triggered a force-close. The patch adds an early check: if msg.short_channel_id does not match the current funding’s SCID, return ChannelError::Ignore instead. Genuine invalid signatures targeting the current SCID still follow the existing verification and error path. A regression test in splicing_tests.rs replays stale signatures after a splice and asserts the channel remains usable.
Changed components
lightning/src/ln/channel.rslightning/src/ln/splicing_tests.rsmsgs::AnnouncementSignatures handlingChannel funding/splicing stateInspect captured patch +96 / −0
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index 51e863c..e62d36f 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -12155,6 +12155,17 @@ where
&mut self, node_signer: &NS, chain_hash: ChainHash, best_block_height: u32,
msg: &msgs::AnnouncementSignatures, user_config: &UserConfig
) -> Result<msgs::ChannelAnnouncement, ChannelError> {
+ // Ignore sigs signed over a `short_channel_id` other than our current one (e.g. stale
+ // pre-splice sigs arriving after our side has promoted). Verifying them against the
+ // current `UnsignedChannelAnnouncement` would always fail the hash check, but per BOLT #7
+ // that's not a protocol violation warranting a force-close.
+ if Some(msg.short_channel_id) != self.funding.get_short_channel_id() {
+ return Err(ChannelError::Ignore(format!(
+ "Ignoring announcement_signatures for short_channel_id {} which does not match our current short_channel_id {:?}",
+ msg.short_channel_id, self.funding.get_short_channel_id(),
+ )));
+ }
+
let announcement = self.get_channel_announcement(node_signer, chain_hash, user_config)?;
let msghash = hash_to_message!(&Sha256d::hash(&announcement.encode()[..])[..]);
diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs
index 41903a5..a6b91a6 100644
--- a/lightning/src/ln/splicing_tests.rs
+++ b/lightning/src/ln/splicing_tests.rs
@@ -2301,6 +2301,91 @@ fn test_splice_confirms_on_both_sides_while_disconnected() {
.remove_watched_txn_and_outputs(prev_funding_outpoint, prev_funding_script);
}
+#[test]
+fn test_stale_announcement_signatures_ignored_after_splice_lock() {
+ // Regression test: a peer may transmit `announcement_signatures` signed over a pre-splice
+ // `short_channel_id` (for example, a stale retransmission or a peer implementation that
+ // hasn't yet caught up to our post-splice promotion). Verifying those sigs against the
+ // post-splice `UnsignedChannelAnnouncement` will always fail the hash check, but that is not
+ // a protocol violation — the spec permits ignoring and the channel should stay open.
+ let chanmon_cfgs = create_chanmon_cfgs(2);
+ let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
+ let mut config = test_default_channel_config();
+ config.channel_handshake_config.announced_channel_max_inbound_htlc_value_in_flight_percentage =
+ 100;
+ let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, Some(config)]);
+ let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
+
+ let node_id_0 = nodes[0].node.get_our_node_id();
+ let node_id_1 = nodes[1].node.get_our_node_id();
+
+ let initial_channel_value_sat = 100_000;
+ // Use the lower-level helper so we get the signed `ChannelAnnouncement` back — the test
+ // needs node 1's pre-splice announcement signatures to replay later.
+ let chan_announcement =
+ create_chan_between_nodes_with_value(&nodes[0], &nodes[1], initial_channel_value_sat, 0);
+ let channel_id = chan_announcement.3;
+ update_nodes_with_chan_announce(
+ &nodes,
+ 0,
+ 1,
+ &chan_announcement.0,
+ &chan_announcement.1,
+ &chan_announcement.2,
+ );
+
+ // Extract node 1's pre-splice signatures from the ChannelAnnouncement. `UnsignedChannelAnnouncement`
+ // orders `node_id_1`/`node_id_2` by serialized pubkey; node 1's sigs are in slot 1 iff node 1's
+ // pubkey is lexicographically smaller.
+ let node_1_is_node_one = node_id_1.serialize() < node_id_0.serialize();
+ let (stale_node_sig, stale_bitcoin_sig) = if node_1_is_node_one {
+ (chan_announcement.0.node_signature_1, chan_announcement.0.bitcoin_signature_1)
+ } else {
+ (chan_announcement.0.node_signature_2, chan_announcement.0.bitcoin_signature_2)
+ };
+
+ // Capture the pre-splice `short_channel_id` — this is the scid the stale sigs sign over.
+ let pre_splice_scid = nodes[0].node.list_channels()[0].short_channel_id.unwrap();
+
+ let outputs = vec![
+ TxOut {
+ value: Amount::from_sat(initial_channel_value_sat / 4),
+ script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(),
+ },
+ TxOut {
+ value: Amount::from_sat(initial_channel_value_sat / 4),
+ script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(),
+ },
+ ];
+ let funding_contribution =
+ initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs).unwrap();
+ let (splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution);
+ mine_transaction(&nodes[0], &splice_tx);
+ mine_transaction(&nodes[1], &splice_tx);
+ lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1);
+
+ // The post-splice scid is now different; confirm that.
+ let post_splice_scid = nodes[0].node.list_channels()[0].short_channel_id.unwrap();
+ assert_ne!(pre_splice_scid, post_splice_scid);
+
+ // Replay node 1's pre-splice announcement signatures, now stale (the current scid is the
+ // post-splice one). This is the exact shape of message a peer would send if it retransmitted
+ // an old `announcement_signatures` across a splice handoff.
+ let stale_sigs = msgs::AnnouncementSignatures {
+ channel_id,
+ short_channel_id: pre_splice_scid,
+ node_signature: stale_node_sig,
+ bitcoin_signature: stale_bitcoin_sig,
+ };
+ nodes[0].node.handle_announcement_signatures(node_id_1, &stale_sigs);
+
+ // No force-close, no outbound error, no events. The channel must still be listed and usable.
+ assert!(nodes[0].node.get_and_clear_pending_events().is_empty());
+ assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty());
+ assert_eq!(nodes[0].node.list_channels().len(), 1);
+ send_payment(&nodes[0], &[&nodes[1]], 1_000_000);
+}
+
#[test]
fn test_propose_splice_while_disconnected() {
do_test_propose_splice_while_disconnected(false);
Why this scored 62/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.