Allow acceptor contribution to RBF splice via tx_init_rbf
What changed, and why it matters
This commit fixes a bug in Lightning Dev Kit's splicing/RBF (Replace-By-Fee) logic. When both channel partners tried to update a splice at the same time, the loser of a tie-breaker was supposed to contribute its inputs/outputs as the 'acceptor' of the RBF transaction, but the code incorrectly made the acceptor contribute nothing. The patch makes the acceptor include its queued splice contribution in the RBF transaction and report it in the response. It is a protocol-correctness bug that could cause stuck or inconsistent splice states, but it is not a clear theft-of-funds vulnerability.
Review and merge. This is a protocol-correctness fix for dual-initiated RBF splices. Monitor for any related edge cases around QuiescentAction lifecycle and ensure the new tests cover the main feerate/budget branches. No emergency response is indicated, but nodes running splicing/RBF should update to avoid stuck splice states.
Security signals we found
Protocol state inconsistency: acceptor's pending QuiescentAction::Splice was ignored during RBF, leading to incomplete funding transactions.
Funds-flow correctness: acceptor inputs/outputs and funding_output_contribution are now included in TxAckRbf, preventing omitted splice contributions.
No explicit authentication/authorization bypass, memory safety issue, or cryptographic flaw in the diff.
Test-only helper changes (remove_watched_by_txid, lock_splice signature change) are not production security signals.
Evidence from the diff
In rust-lightning’s channel splice/RBF handling, handle_tx_init_rbf previously set our_funding_contribution to SignedAmount::ZERO for the acceptor and returned funding_output_contribution: None in TxAckRbf. The fix calls resolve_queued_contribution() and take_queued_funding_contribution() in the tx_init_rbf handler (mirroring splice_init), derives acceptor inputs/outputs via for_acceptor_at_feerate(), and populates funding_output_contribution when non-zero. Tests are added/updated for simultaneous RBF tie-break scenarios, including cases where the acceptor’s contribution is adjusted or deferred because of feerate/budget constraints. A test helper is also refactored to expect DiscardFunding events for replaced splice candidates.
Changed components
lightning/src/ln/channel.rs - handle_tx_init_rbf RBF acceptor contribution logiclightning/src/ln/splicing_tests.rs - new tie-break RBF tests and lock_splice helper updateslightning-tests/src/upgrade_downgrade_tests.rs - lock_splice call site updatelightning/src/util/test_utils.rs - TestChainSource::remove_watched_by_txid helperInspect captured patch +403 / −98
diff --git a/lightning-tests/src/upgrade_downgrade_tests.rs b/lightning-tests/src/upgrade_downgrade_tests.rs
index f68615d..7f607bb 100644
--- a/lightning-tests/src/upgrade_downgrade_tests.rs
+++ b/lightning-tests/src/upgrade_downgrade_tests.rs
@@ -466,7 +466,7 @@ fn do_test_0_1_htlc_forward_after_splice(fail_htlc: bool) {
}
let splice_locked = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceLocked, node_b_id);
- lock_splice(&nodes[0], &nodes[1], &splice_locked, false);
+ lock_splice(&nodes[0], &nodes[1], &splice_locked, false, &[]);
for node in nodes.iter() {
connect_blocks(node, EXTRA_BLOCKS_BEFORE_FAIL - ANTI_REORG_DELAY);
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index 96a147a..5587d42 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -12562,9 +12562,26 @@ where
&mut self, msg: &msgs::TxInitRbf, entropy_source: &ES, holder_node_id: &PublicKey,
fee_estimator: &LowerBoundedFeeEstimator<F>, logger: &L,
) -> Result<msgs::TxAckRbf, ChannelError> {
- let our_funding_contribution = SignedAmount::ZERO;
- let rbf_funding =
- self.validate_tx_init_rbf(msg, our_funding_contribution, fee_estimator)?;
+ let feerate = FeeRate::from_sat_per_kwu(msg.feerate_sat_per_1000_weight as u64);
+ let (our_funding_contribution, holder_balance) =
+ self.resolve_queued_contribution(feerate, logger);
+
+ let rbf_funding = self.validate_tx_init_rbf(
+ msg,
+ our_funding_contribution.unwrap_or(SignedAmount::ZERO),
+ fee_estimator,
+ )?;
+
+ let (our_funding_inputs, our_funding_outputs) = if our_funding_contribution.is_some() {
+ self.take_queued_funding_contribution()
+ .expect("queued_funding_contribution was Some")
+ .for_acceptor_at_feerate(feerate, holder_balance.unwrap())
+ .expect("feerate compatibility already checked")
+ .into_tx_parts()
+ } else {
+ Default::default()
+ };
+ let our_funding_contribution = our_funding_contribution.unwrap_or(SignedAmount::ZERO);
log_info!(
logger,
@@ -12583,15 +12600,19 @@ where
prev_funding_input,
msg.locktime,
msg.feerate_sat_per_1000_weight,
- Vec::new(),
- Vec::new(),
+ our_funding_inputs,
+ our_funding_outputs,
);
let pending_splice = self.pending_splice.as_mut().expect("pending_splice should exist");
pending_splice.funding_negotiation = Some(funding_negotiation);
Ok(msgs::TxAckRbf {
channel_id: self.context.channel_id,
- funding_output_contribution: None,
+ funding_output_contribution: if our_funding_contribution != SignedAmount::ZERO {
+ Some(our_funding_contribution.to_sat())
+ } else {
+ None
+ },
})
}
diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs
index b09f5aa..f7b867c 100644
--- a/lightning/src/ln/splicing_tests.rs
+++ b/lightning/src/ln/splicing_tests.rs
@@ -40,7 +40,8 @@ use bitcoin::secp256k1::ecdsa::Signature;
use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey};
use bitcoin::transaction::Version;
use bitcoin::{
- Amount, FeeRate, OutPoint as BitcoinOutPoint, Psbt, ScriptBuf, Transaction, TxOut, WPubkeyHash,
+ Amount, FeeRate, OutPoint as BitcoinOutPoint, Psbt, ScriptBuf, Transaction, TxOut, Txid,
+ WPubkeyHash,
};
#[test]
@@ -664,18 +665,18 @@ pub fn lock_splice_after_blocks<'a, 'b, 'c, 'd>(
let node_id_b = node_b.node.get_our_node_id();
let splice_locked_for_node_b =
get_event_msg!(node_a, MessageSendEvent::SendSpliceLocked, node_id_b);
- lock_splice(node_a, node_b, &splice_locked_for_node_b, false)
+ lock_splice(node_a, node_b, &splice_locked_for_node_b, false, &[])
}
pub fn lock_splice<'a, 'b, 'c, 'd>(
node_a: &'a Node<'b, 'c, 'd>, node_b: &'a Node<'b, 'c, 'd>,
- splice_locked_for_node_b: &msgs::SpliceLocked, is_0conf: bool,
+ splice_locked_for_node_b: &msgs::SpliceLocked, is_0conf: bool, expected_discard_txids: &[Txid],
) -> Option<MessageSendEvent> {
- let (prev_funding_outpoint, prev_funding_script) = node_a
+ let prev_funding_txid = node_a
.chain_monitor
.chain_monitor
.get_monitor(splice_locked_for_node_b.channel_id)
- .map(|monitor| (monitor.get_funding_txo(), monitor.get_funding_script()))
+ .map(|monitor| monitor.get_funding_txo().txid)
.unwrap();
let node_id_a = node_a.node.get_our_node_id();
@@ -707,10 +708,32 @@ pub fn lock_splice<'a, 'b, 'c, 'd>(
}
}
- expect_channel_ready_event(&node_a, &node_id_b);
- check_added_monitors(&node_a, 1);
- expect_channel_ready_event(&node_b, &node_id_a);
- check_added_monitors(&node_b, 1);
+ let mut all_discard_txids = Vec::new();
+ let expected_num_events = 1 + expected_discard_txids.len();
+ for node in [node_a, node_b] {
+ let events = node.node.get_and_clear_pending_events();
+ assert_eq!(events.len(), expected_num_events, "{events:?}");
+ assert!(matches!(events[0], Event::ChannelReady { .. }));
+ let discard_txids: Vec<_> = events[1..]
+ .iter()
+ .map(|e| match e {
+ Event::DiscardFunding { funding_info: FundingInfo::Tx { transaction }, .. } => {
+ transaction.compute_txid()
+ },
+ Event::DiscardFunding {
+ funding_info: FundingInfo::OutPoint { outpoint }, ..
+ } => outpoint.txid,
+ other => panic!("Expected DiscardFunding, got {:?}", other),
+ })
+ .collect();
+ for txid in expected_discard_txids {
+ assert!(discard_txids.contains(txid), "Missing DiscardFunding for txid {}", txid);
+ }
+ if all_discard_txids.is_empty() {
+ all_discard_txids = discard_txids;
+ }
+ check_added_monitors(node, 1);
+ }
if !is_0conf {
let mut msg_events = node_a.node.get_and_clear_pending_msg_events();
@@ -735,14 +758,32 @@ pub fn lock_splice<'a, 'b, 'c, 'd>(
// Remove the corresponding outputs and transactions the chain source is watching for the
// old funding as it is no longer being tracked.
- node_a
- .chain_source
- .remove_watched_txn_and_outputs(prev_funding_outpoint, prev_funding_script.clone());
- node_b.chain_source.remove_watched_txn_and_outputs(prev_funding_outpoint, prev_funding_script);
+ for node in [node_a, node_b] {
+ node.chain_source.remove_watched_by_txid(prev_funding_txid);
+ for txid in &all_discard_txids {
+ node.chain_source.remove_watched_by_txid(*txid);
+ }
+ }
node_b_stfu
}
+pub fn lock_rbf_splice_after_blocks<'a, 'b, 'c, 'd>(
+ node_a: &'a Node<'b, 'c, 'd>, node_b: &'a Node<'b, 'c, 'd>, tx: &Transaction, num_blocks: u32,
+ expected_discard_txids: &[Txid],
+) -> Option<MessageSendEvent> {
+ mine_transaction(node_a, tx);
+ mine_transaction(node_b, tx);
+
+ connect_blocks(node_a, num_blocks);
+ connect_blocks(node_b, num_blocks);
+
+ let node_id_b = node_b.node.get_our_node_id();
+ let splice_locked_for_node_b =
+ get_event_msg!(node_a, MessageSendEvent::SendSpliceLocked, node_id_b);
+ lock_splice(node_a, node_b, &splice_locked_for_node_b, false, expected_discard_txids)
+}
+
#[test]
fn test_splice_state_reset_on_disconnect() {
do_test_splice_state_reset_on_disconnect(false);
@@ -2962,13 +3003,13 @@ fn do_test_splice_with_inflight_htlc_forward_and_resolution(expire_scid_pre_forw
connect_blocks(node, ANTI_REORG_DELAY - 2);
}
let splice_locked = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceLocked, node_id_1);
- lock_splice(&nodes[0], &nodes[1], &splice_locked, false);
+ lock_splice(&nodes[0], &nodes[1], &splice_locked, false, &[]);
for node in &nodes {
connect_blocks(node, 1);
}
let splice_locked = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceLocked, node_id_2);
- lock_splice(&nodes[1], &nodes[2], &splice_locked, false);
+ lock_splice(&nodes[1], &nodes[2], &splice_locked, false, &[]);
if expire_scid_pre_forward {
for node in &nodes {
@@ -4150,13 +4191,6 @@ fn test_splice_rbf_acceptor_basic() {
// Step 1: Complete a splice-in from node 0.
let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value);
- // Save the pre-splice funding outpoint before splice_channel modifies the monitor.
- let original_funding_outpoint = nodes[0]
- .chain_monitor
- .chain_monitor
- .get_monitor(channel_id)
- .map(|monitor| (monitor.get_funding_txo(), monitor.get_funding_script()))
- .unwrap();
let (first_splice_tx, new_funding_script) =
splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution);
@@ -4190,75 +4224,14 @@ fn test_splice_rbf_acceptor_basic() {
expect_splice_pending_event(&nodes[0], &node_id_1);
expect_splice_pending_event(&nodes[1], &node_id_0);
- // Step 11: Mine and lock.
- mine_transaction(&nodes[0], &rbf_tx);
- mine_transaction(&nodes[1], &rbf_tx);
-
- // Lock the RBF splice. We can't use lock_splice_after_blocks directly because the splice
- // promotion generates DiscardFunding events for the old (replaced) splice candidate.
- connect_blocks(&nodes[0], ANTI_REORG_DELAY - 1);
- connect_blocks(&nodes[1], ANTI_REORG_DELAY - 1);
-
- let splice_locked_b = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceLocked, node_id_1);
- nodes[1].node.handle_splice_locked(node_id_0, &splice_locked_b);
-
- let mut msg_events = nodes[1].node.get_and_clear_pending_msg_events();
- assert_eq!(msg_events.len(), 2, "{msg_events:?}");
- let splice_locked_a =
- if let MessageSendEvent::SendSpliceLocked { msg, .. } = msg_events.remove(0) {
- msg
- } else {
- panic!("Expected SendSpliceLocked, got {:?}", msg_events[0]);
- };
- let announcement_sigs_b =
- if let MessageSendEvent::SendAnnouncementSignatures { msg, .. } = msg_events.remove(0) {
- msg
- } else {
- panic!("Expected SendAnnouncementSignatures");
- };
- nodes[0].node.handle_splice_locked(node_id_1, &splice_locked_a);
- nodes[0].node.handle_announcement_signatures(node_id_1, &announcement_sigs_b);
-
- // Expect ChannelReady + DiscardFunding for the old splice candidate on both nodes.
- let events_a = nodes[0].node.get_and_clear_pending_events();
- assert_eq!(events_a.len(), 2, "{events_a:?}");
- assert!(matches!(events_a[0], Event::ChannelReady { .. }));
- assert!(matches!(events_a[1], Event::DiscardFunding { .. }));
- check_added_monitors(&nodes[0], 1);
-
- let events_b = nodes[1].node.get_and_clear_pending_events();
- assert_eq!(events_b.len(), 2, "{events_b:?}");
- assert!(matches!(events_b[0], Event::ChannelReady { .. }));
- assert!(matches!(events_b[1], Event::DiscardFunding { .. }));
- check_added_monitors(&nodes[1], 1);
-
- // Complete the announcement exchange.
- let mut msg_events = nodes[0].node.get_and_clear_pending_msg_events();
- assert_eq!(msg_events.len(), 2, "{msg_events:?}");
- if let MessageSendEvent::SendAnnouncementSignatures { msg, .. } = msg_events.remove(0) {
- nodes[1].node.handle_announcement_signatures(node_id_0, &msg);
- } else {
- panic!("Expected SendAnnouncementSignatures");
- }
- assert!(matches!(msg_events.remove(0), MessageSendEvent::BroadcastChannelAnnouncement { .. }));
-
- let mut msg_events = nodes[1].node.get_and_clear_pending_msg_events();
- assert_eq!(msg_events.len(), 1, "{msg_events:?}");
- assert!(matches!(msg_events.remove(0), MessageSendEvent::BroadcastChannelAnnouncement { .. }));
-
- // Clean up old watched outpoints from the chain source.
- // The original channel's funding outpoint and the first (replaced) splice's funding outpoint
- // are still being watched but are no longer tracked by the deserialized monitor.
- let (orig_outpoint, orig_script) = original_funding_outpoint;
- let first_splice_funding_idx =
- first_splice_tx.output.iter().position(|o| o.script_pubkey == new_funding_script).unwrap();
- let first_splice_outpoint =
- OutPoint { txid: first_splice_tx.compute_txid(), index: first_splice_funding_idx as u16 };
- for node in &nodes {
- node.chain_source.remove_watched_txn_and_outputs(orig_outpoint, orig_script.clone());
- node.chain_source
- .remove_watched_txn_and_outputs(first_splice_outpoint, new_funding_script.clone());
- }
+ // Step 11: Mine, lock, and verify DiscardFunding for the replaced splice candidate.
+ lock_rbf_splice_after_blocks(
+ &nodes[0],
+ &nodes[1],
+ &rbf_tx,
+ ANTI_REORG_DELAY - 1,
+ &[first_splice_tx.compute_txid()],
+ );
}
#[test]
@@ -4585,3 +4558,310 @@ fn test_splice_rbf_not_quiescence_initiator() {
let tx_abort = get_event_msg!(nodes[0], MessageSendEvent::SendTxAbort, node_id_1);
assert_eq!(tx_abort.channel_id, channel_id);
}
+
+#[test]
+fn test_splice_rbf_both_contribute_tiebreak() {
+ let min_rbf_feerate = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24);
+ let feerate = FeeRate::from_sat_per_kwu(min_rbf_feerate);
+ let added_value = Amount::from_sat(50_000);
+ do_test_splice_rbf_tiebreak(feerate, feerate, added_value, true);
+}
+
+#[test]
+fn test_splice_rbf_tiebreak_higher_feerate() {
+ // Node 0 (winner) uses a higher feerate than node 1 (loser). Node 1's change output is
+ // adjusted (reduced) to accommodate the higher feerate. Negotiation succeeds.
+ let min_rbf_feerate = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24);
+ do_test_splice_rbf_tiebreak(
+ FeeRate::from_sat_per_kwu(min_rbf_feerate * 3),
+ FeeRate::from_sat_per_kwu(min_rbf_feerate),
+ Amount::from_sat(50_000),
+ true,
+ );
+}
+
+#[test]
+fn test_splice_rbf_tiebreak_lower_feerate() {
+ // Node 0 (winner) uses a lower feerate than node 1 (loser). Since the initiator's feerate
+ // is below node 1's minimum, node 1 proceeds without contribution and will retry via a new
+ // splice at its preferred feerate after the RBF locks.
+ let min_rbf_feerate = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24);
+ do_test_splice_rbf_tiebreak(
+ FeeRate::from_sat_per_kwu(min_rbf_feerate),
+ FeeRate::from_sat_per_kwu(min_rbf_feerate * 3),
+ Amount::from_sat(50_000),
+ false,
+ );
+}
+
+#[test]
+fn test_splice_rbf_tiebreak_feerate_too_high() {
+ // Node 0 (winner) uses a feerate high enough that node 1's (loser) contribution cannot
+ // cover the fees. Node 1 proceeds without its contribution (QuiescentAction is preserved
+ // for a future splice). The RBF completes with only node 0's inputs/outputs.
+ let min_rbf_feerate = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24);
+ do_test_splice_rbf_tiebreak(
+ FeeRate::from_sat_per_kwu(20_000),
+ FeeRate::from_sat_per_kwu(min_rbf_feerate),
+ Amount::from_sat(95_000),
+ false,
+ );
+}
+
+/// Runs the tie-breaker test with the given per-node feerates and node 1's splice value.
+///
+/// Both nodes call `rbf_channel` + `funding_contributed`, both send STFU, and node 0 (the outbound
+/// channel funder) wins the quiescence tie-break. The loser (node 1) becomes the acceptor. Whether
+/// node 1 contributes to the RBF transaction depends on the feerate and budget constraints.
+///
+/// `expect_acceptor_contributes` asserts the expected outcome: whether node 1's `tx_ack_rbf`
+/// includes a funding output contribution.
+pub fn do_test_splice_rbf_tiebreak(
+ rbf_feerate_0: FeeRate, rbf_feerate_1: FeeRate, node_1_splice_value: Amount,
+ expect_acceptor_contributes: bool,
+) {
+ 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 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;
+ let (_, _, channel_id, _) =
+ create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0);
+
+ let added_value = Amount::from_sat(50_000);
+ provide_utxo_reserves(&nodes, 2, added_value * 2);
+ // Complete an initial splice-in from node 0.
+ let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value);
+ let (first_splice_tx, new_funding_script) =
+ splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution);
+
+ // Provide more UTXOs for both nodes' RBF attempts.
+ provide_utxo_reserves(&nodes, 2, added_value * 2);
+
+ // Node 0 calls rbf_channel + funding_contributed.
+ let node_0_funding_contribution =
+ do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate_0);
+
+ // Node 1 calls rbf_channel + funding_contributed.
+ let node_1_funding_contribution = do_initiate_rbf_splice_in(
+ &nodes[1],
+ &nodes[0],
+ channel_id,
+ node_1_splice_value,
+ rbf_feerate_1,
+ );
+
+ // Both nodes sent STFU (both have awaiting_quiescence set).
+ let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1);
+ assert!(stfu_0.initiator);
+ let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0);
+ assert!(stfu_1.initiator);
+
+ // Exchange STFUs. Node 0 is the outbound channel funder and wins the tie-break.
+ // Node 1 handles node 0's STFU first — it already sent its own STFU (local_stfu_sent is set),
+ // so this goes through the tie-break path. Node 1 loses (is_outbound = false) and becomes the
+ // acceptor. Its quiescent_action is preserved for the tx_init_rbf handler.
+ nodes[1].node.handle_stfu(node_id_0, &stfu_0);
+ assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty());
+
+ // Node 0 handles node 1's STFU — it already sent its own STFU, so tie-break again.
+ // Node 0 wins (is_outbound = true), consumes its quiescent_action, and sends tx_init_rbf.
+ nodes[0].node.handle_stfu(node_id_1, &stfu_1);
+
+ // Node 0 sends tx_init_rbf.
+ let tx_init_rbf = get_event_msg!(nodes[0], MessageSendEvent::SendTxInitRbf, node_id_1);
+ assert_eq!(tx_init_rbf.channel_id, channel_id);
+ assert_eq!(tx_init_rbf.feerate_sat_per_1000_weight, rbf_feerate_0.to_sat_per_kwu() as u32);
+
+ // Node 1 handles tx_init_rbf — its quiescent_action is consumed, adjusting its contribution
+ // for node 0's feerate. Whether it contributes depends on the feerate and budget constraints.
+ nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf);
+ let tx_ack_rbf = get_event_msg!(nodes[1], MessageSendEvent::SendTxAckRbf, node_id_0);
+ assert_eq!(tx_ack_rbf.channel_id, channel_id);
+
+ // Node 0 handles tx_ack_rbf.
+ let acceptor_contributes = tx_ack_rbf.funding_output_contribution.is_some();
+ assert_eq!(
+ acceptor_contributes, expect_acceptor_contributes,
+ "Expected acceptor contribution: {}, got: {}",
+ expect_acceptor_contributes, acceptor_contributes,
+ );
+ nodes[0].node.handle_tx_ack_rbf(node_id_1, &tx_ack_rbf);
+
+ if acceptor_contributes {
+ // Capture change output values for assertions.
+ let node_0_change = node_0_funding_contribution
+ .change_output()
+ .expect("splice-in should have a change output")
+ .clone();
+ let node_1_change = node_1_funding_contribution
+ .change_output()
+ .expect("splice-in should have a change output")
+ .clone();
+
+ // Complete interactive funding negotiation with both parties' inputs/outputs.
+ complete_interactive_funding_negotiation_for_both(
+ &nodes[0],
+ &nodes[1],
+ channel_id,
+ node_0_funding_contribution,
+ Some(node_1_funding_contribution),
+ tx_ack_rbf.funding_output_contribution.unwrap(),
+ new_funding_script.clone(),
+ );
+
+ // Sign (acceptor has contribution) and broadcast.
+ let (rbf_tx, splice_locked) = sign_interactive_funding_tx_with_acceptor_contribution(
+ &nodes[0], &nodes[1], false, true,
+ );
+ assert!(splice_locked.is_none());
+
+ // The initiator's change output should remain unchanged (no feerate adjustment).
+ let initiator_change_in_tx = rbf_tx
+ .output
+ .iter()
+ .find(|o| o.script_pubkey == node_0_change.script_pubkey)
+ .expect("Initiator's change output should be in the RBF transaction");
+ assert_eq!(
+ initiator_change_in_tx.value, node_0_change.value,
+ "Initiator's change output should remain unchanged",
+ );
+
+ // The acceptor's change output should be adjusted based on the feerate difference.
+ let acceptor_change_in_tx = rbf_tx
+ .output
+ .iter()
+ .find(|o| o.script_pubkey == node_1_change.script_pubkey)
+ .expect("Acceptor's change output should be in the RBF transaction");
+ if rbf_feerate_0 <= rbf_feerate_1 {
+ // Initiator's feerate <= acceptor's original: the acceptor's change increases because
+ // is_initiator=false has lower weight, and the feerate is the same or lower.
+ assert!(
+ acceptor_change_in_tx.value > node_1_change.value,
+ "Acceptor's change should increase when initiator feerate ({}) <= acceptor \
+ feerate ({}): adjusted {} vs original {}",
+ rbf_feerate_0.to_sat_per_kwu(),
+ rbf_feerate_1.to_sat_per_kwu(),
+ acceptor_change_in_tx.value,
+ node_1_change.value,
+ );
+ } else {
+ // Initiator's feerate > acceptor's original: the higher feerate more than compensates
+ // for the lower weight, so the acceptor's change decreases.
+ assert!(
+ acceptor_change_in_tx.value < node_1_change.value,
+ "Acceptor's change should decrease when initiator feerate ({}) > acceptor \
+ feerate ({}): adjusted {} vs original {}",
+ rbf_feerate_0.to_sat_per_kwu(),
+ rbf_feerate_1.to_sat_per_kwu(),
+ acceptor_change_in_tx.value,
+ node_1_change.value,
+ );
+ }
+
+ expect_splice_pending_event(&nodes[0], &node_id_1);
+ expect_splice_pending_event(&nodes[1], &node_id_0);
+
+ // Mine, lock, and verify DiscardFunding for the replaced splice candidate.
+ lock_rbf_splice_after_blocks(
+ &nodes[0],
+ &nodes[1],
+ &rbf_tx,
+ ANTI_REORG_DELAY - 1,
+ &[first_splice_tx.compute_txid()],
+ );
+ } else {
+ // Acceptor does not contribute — complete with only node 0's inputs/outputs.
+ complete_interactive_funding_negotiation_for_both(
+ &nodes[0],
+ &nodes[1],
+ channel_id,
+ node_0_funding_contribution,
+ None,
+ 0,
+ new_funding_script.clone(),
+ );
+
+ // Sign (acceptor has no contribution) and broadcast.
+ let (rbf_tx, splice_locked) = sign_interactive_funding_tx_with_acceptor_contribution(
+ &nodes[0], &nodes[1], false, false,
+ );
+ assert!(splice_locked.is_none());
+
+ expect_splice_pending_event(&nodes[0], &node_id_1);
+ expect_splice_pending_event(&nodes[1], &node_id_0);
+
+ // Mine, lock, and verify DiscardFunding for the replaced splice candidate.
+ // Node 1's QuiescentAction was preserved, so after splice_locked it re-initiates
+ // quiescence to retry its contribution in a future splice.
+ let node_b_stfu = lock_rbf_splice_after_blocks(
+ &nodes[0],
+ &nodes[1],
+ &rbf_tx,
+ ANTI_REORG_DELAY - 1,
+ &[first_splice_tx.compute_txid()],
+ );
+ let stfu_1 = if let Some(MessageSendEvent::SendStfu { msg, .. }) = node_b_stfu {
+ msg
+ } else {
+ panic!("Expected SendStfu from node 1");
+ };
+ assert!(stfu_1.initiator);
+
+ // === Part 2: Node 1's preserved QuiescentAction leads to a new splice ===
+ //
+ // After splice_locked, pending_splice is None. So when stfu() consumes the
+ // QuiescentAction, it sends SpliceInit (not TxInitRbf), starting a brand new splice.
+
+ // Node 0 receives node 1's STFU and responds with its own STFU.
+ nodes[0].node.handle_stfu(node_id_1, &stfu_1);
+ let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1);
+
+ // Node 1 receives STFU → quiescence established → node 1 is the initiator →
+ // sends SpliceInit.
+ nodes[1].node.handle_stfu(node_id_0, &stfu_0);
+ let splice_init = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceInit, node_id_0);
+
+ // Node 0 handles SpliceInit → sends SpliceAck.
+ nodes[0].node.handle_splice_init(node_id_1, &splice_init);
+ let splice_ack = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceAck, node_id_1);
+
+ // Node 1 handles SpliceAck → starts interactive tx construction.
+ nodes[1].node.handle_splice_ack(node_id_0, &splice_ack);
+
+ // Compute the new funding script from the splice pubkeys.
+ let new_funding_script_2 = chan_utils::make_funding_redeemscript(
+ &splice_init.funding_pubkey,
+ &splice_ack.funding_pubkey,
+ )
+ .to_p2wsh();
+
+ // Complete interactive funding negotiation with node 1 as initiator (only node 1
+ // contributes).
+ complete_interactive_funding_negotiation(
+ &nodes[1],
+ &nodes[0],
+ channel_id,
+ node_1_funding_contribution,
+ new_funding_script_2,
+ );
+
+ // Sign (no acceptor contribution) and broadcast.
+ let (new_splice_tx, splice_locked) =
+ sign_interactive_funding_tx(&nodes[1], &nodes[0], false);
+ assert!(splice_locked.is_none());
+
+ expect_splice_pending_event(&nodes[1], &node_id_0);
+ expect_splice_pending_event(&nodes[0], &node_id_1);
+
+ // Mine and lock.
+ mine_transaction(&nodes[1], &new_splice_tx);
+ mine_transaction(&nodes[0], &new_splice_tx);
+
+ lock_splice_after_blocks(&nodes[1], &nodes[0], ANTI_REORG_DELAY - 1);
+ }
+}
diff --git a/lightning/src/util/test_utils.rs b/lightning/src/util/test_utils.rs
index 22be436..6c19af5 100644
--- a/lightning/src/util/test_utils.rs
+++ b/lightning/src/util/test_utils.rs
@@ -2143,6 +2143,10 @@ impl TestChainSource {
self.watched_outputs.lock().unwrap().remove(&(outpoint, script_pubkey.clone()));
self.watched_txn.lock().unwrap().remove(&(outpoint.txid, script_pubkey));
}
+ pub fn remove_watched_by_txid(&self, txid: Txid) {
+ self.watched_outputs.lock().unwrap().retain(|(op, _)| op.txid != txid);
+ self.watched_txn.lock().unwrap().retain(|(tid, _)| *tid != txid);
+ }
}
impl UtxoLookup for TestChainSource {
Why this scored 46/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.