Clear pending signer state when aborting splice RBF
What changed, and why it matters
This commit fixes a state-cleanup bug in Lightning Dev Kit's splice RBF (fee-bump) flow. When a user aborts a splice while waiting for an asynchronous hardware signer, the code now properly clears an internal 'signer still has pending funding work' flag. Before the fix, that flag could remain set, causing a later 'signer is ready' callback to try to continue signing a funding transaction that no longer exists. The patch is small and defensive; the main risk is a confused internal state leading to unexpected retries or errors, not direct theft of funds.
Apply the patch. Review other splice/RBF abort paths for similar stale pending-signer flags. Consider whether `signer_unblocked` should guard against missing signing sessions more defensively.
Security signals we found
State inconsistency between signing session and pending-signer flag
Asynchronous signer unblock callback retrying stale funding state
Regression test demonstrates abort-then-unblock behavior
No explicit security advisory or CVE referenced in commit
Evidence from the diff
In lightning/src/ln/channel.rs, abort_splice_negotiation now sets self.context.signer_pending_funding = false when tearing down an AwaitingSignatures interactive transaction signing session. The added regression test simulates an async signer blocking SignCounterpartyCommitment, aborts the RBF splice, re-enables the signer, calls signer_unblocked, and verifies no stale message events are produced. The bug was a missing state reset: interactive_tx_signing_session was already removed, but signer_pending_funding stayed true, so the unblock callback would attempt to resume a non-existent signing session.
Changed components
lightning/src/ln/channel.rsSplice RBF negotiationInteractive transaction signing sessionAsynchronous signer callback handlingInspect captured patch +106 / −0
### lightning/src/ln/channel.rs
@@ -7777,6 +7777,7 @@ where
self.exit_quiescence();
if current_is_awaiting_signatures {
self.context.interactive_tx_signing_session.take();
+ self.context.signer_pending_funding = false;
}
splice_funding_failed
### lightning/src/ln/splicing_tests.rs
@@ -7366,6 +7366,111 @@ fn test_aborted_rbf_ignores_inflight_commitment_signed() {
lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1);
}
+#[test]
+fn test_pending_rbf_signer_cleared_on_abort() {
+ 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 (_, _, channel_id, _) =
+ create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 0);
+ let added_value = Amount::from_sat(50_000);
+ provide_utxo_reserves(&nodes, 2, added_value * 2);
+ let contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value);
+ let (first_splice_tx, funding_script) =
+ splice_channel(&nodes[0], &nodes[1], channel_id, contribution);
+
+ provide_utxo_reserves(&nodes, 2, added_value * 2);
+ let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64 + 25);
+ let rbf_contribution = do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, feerate);
+ let acceptor_contribution =
+ do_initiate_splice_in_at_feerate(&nodes[1], &nodes[0], channel_id, added_value, feerate);
+ let tx_ack_rbf = complete_rbf_handshake(&nodes[0], &nodes[1]);
+ complete_interactive_funding_negotiation_for_both(
+ &nodes[0],
+ &nodes[1],
+ channel_id,
+ rbf_contribution.clone(),
+ Some(acceptor_contribution),
+ tx_ack_rbf.funding_output_contribution.unwrap_or(0),
+ funding_script,
+ );
+ let node_id_0 = nodes[0].node.get_our_node_id();
+ let node_id_1 = nodes[1].node.get_our_node_id();
+ let expected_acceptor_rbf_contribution =
+ nodes[1].node.list_channels()[0].splice_details.as_ref().unwrap().candidates[1]
+ .contribution
+ .clone()
+ .expect("acceptor contributed to the RBF");
+
+ nodes[0].disable_channel_signer_op(
+ &node_id_1,
+ &channel_id,
+ SignerOp::SignCounterpartyCommitment,
+ );
+ match get_event!(nodes[0], Event::FundingTransactionReadyForSigning) {
+ Event::FundingTransactionReadyForSigning {
+ channel_id,
+ counterparty_node_id,
+ unsigned_transaction,
+ ..
+ } => {
+ let partially_signed_tx = nodes[0].wallet_source.sign_tx(unsigned_transaction).unwrap();
+ nodes[0]
+ .node
+ .funding_transaction_signed(&channel_id, &counterparty_node_id, partially_signed_tx)
+ .unwrap();
+ },
+ other => panic!("Unexpected event {other:?}"),
+ }
+
+ let _ = get_event!(nodes[1], Event::FundingTransactionReadyForSigning);
+ assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty());
+
+ let tx_abort = msgs::TxAbort { channel_id, data: b"Aborting pending splice".to_vec() };
+ nodes[0].node.handle_tx_abort(node_id_1, &tx_abort);
+ let events = nodes[0].node.get_and_clear_pending_events();
+ assert!(
+ matches!(
+ events.as_slice(),
+ [Event::SpliceNegotiationFailed {
+ channel_id: failed_channel_id,
+ contribution: Some(failed_contribution),
+ reason: NegotiationFailureReason::CounterpartyAborted { msg },
+ ..
+ }] if *failed_channel_id == channel_id
+ && failed_contribution == &rbf_contribution
+ && msg.0 == "Aborting pending splice"
+ ),
+ "{events:?}"
+ );
+ let tx_abort_ack = get_event_msg!(nodes[0], MessageSendEvent::SendTxAbort, node_id_1);
+ nodes[1].node.handle_tx_abort(node_id_0, &tx_abort_ack);
+ expect_failed_rbf_events(
+ &nodes[1],
+ &channel_id,
+ &expected_acceptor_rbf_contribution,
+ NegotiationFailureReason::CounterpartyAborted {
+ msg: UntrustedString("Acknowledged tx_abort".to_owned()),
+ },
+ );
+ let final_tx_abort_ack = get_event_msg!(nodes[1], MessageSendEvent::SendTxAbort, node_id_0);
+ nodes[0].node.handle_tx_abort(node_id_1, &final_tx_abort_ack);
+
+ nodes[0].enable_channel_signer_op(
+ &node_id_1,
+ &channel_id,
+ SignerOp::SignCounterpartyCommitment,
+ );
+ nodes[0].node.signer_unblocked(None);
+ assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty());
+
+ mine_transaction(&nodes[0], &first_splice_tx);
+ mine_transaction(&nodes[1], &first_splice_tx);
+ lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1);
+}
+
#[test]
fn test_splice_rbf_after_splice_locked() {
// Test that tx_init_rbf is rejected when the counterparty has already sent splice_locked.Why this scored 44/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.