Handle tx_ack_rbf on the initiator side
What changed, and why it matters
This commit adds support in the Lightning Dev Kit for handling a specific protocol message (tx_ack_rbf) when a channel participant initiates a fee-bump (RBF) of a pending splice transaction. Previously, receiving this message caused an error. The change also fixes a related bug where resetting splice state could incorrectly discard a completed signing session from an earlier round, which could trigger a debug assertion. The commit is primarily a feature implementation with a defensive bugfix, not a disclosed security vulnerability.
Review as normal feature/bugfix commit. No immediate security response required. Verify that state-machine transitions and session-lifecycle rules are covered by the expanded tests and that the new handler correctly enforces quiescence ordering.
Security signals we found
New message handler validates state before processing (awaiting_ack_context / take_awaiting_ack_context)
WarnAndDisconnect returned on unexpected funding-negotiation states, reducing protocol desynchronization risk
Fixes false debug assertion from premature clearing of interactive_tx_signing_session during RBF
Adds test for non-quiescence-initiator rejection to prevent conflicting RBF attempts
Evidence from the diff
The patch implements the initiator-side handler for tx_ack_rbf in dual-funded splicing/RBF flows. It validates the acceptor’s response (funding contribution, feerate, negotiated candidate), constructs a new FundingScope, and transitions the funding negotiation into ConstructingTransaction. It refactors splice_ack to share the same FundingNegotiation::for_initiator helper. A second change in reset_pending_splice_state ensures the interactive signing session is only cleared when the current round is in AwaitingSignatures, preserving the prior round’s session when a later RBF round is in AwaitingAck or ConstructingTransaction. Tests are expanded to cover the full initiator RBF flow and a non-initiator rejection case.
Changed components
lightning/src/ln/channel.rslightning/src/ln/channelmanager.rslightning/src/ln/splicing_tests.rsInspect captured patch +417 / −87
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index d24e604..96a147a 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -2969,6 +2969,32 @@ impl FundingNegotiation {
FundingNegotiation::AwaitingSignatures { is_initiator, .. } => *is_initiator,
}
}
+ fn for_initiator<SP: SignerProvider, ES: EntropySource>(
+ funding: FundingScope, context: &ChannelContext<SP>,
+ funding_negotiation_context: FundingNegotiationContext, entropy_source: &ES,
+ holder_node_id: &PublicKey,
+ ) -> (FundingNegotiation, Option<InteractiveTxMessageSend>) {
+ let funding_feerate_sat_per_1000_weight =
+ funding_negotiation_context.funding_feerate_sat_per_1000_weight;
+ let (interactive_tx_constructor, tx_msg_opt) = funding_negotiation_context
+ .into_interactive_tx_constructor(
+ context,
+ &funding,
+ entropy_source,
+ holder_node_id.clone(),
+ );
+ debug_assert!(tx_msg_opt.is_some());
+
+ (
+ FundingNegotiation::ConstructingTransaction {
+ funding,
+ funding_feerate_sat_per_1000_weight,
+ interactive_tx_constructor,
+ },
+ tx_msg_opt,
+ )
+ }
+
fn for_acceptor<SP: SignerProvider, ES: EntropySource>(
funding: FundingScope, context: &ChannelContext<SP>, entropy_source: &ES,
holder_node_id: &PublicKey, our_funding_contribution: SignedAmount,
@@ -3003,6 +3029,43 @@ impl FundingNegotiation {
}
impl PendingFunding {
+ fn awaiting_ack_context(
+ &self, msg_name: &str,
+ ) -> Result<(&FundingNegotiationContext, &PublicKey), ChannelError> {
+ match &self.funding_negotiation {
+ Some(FundingNegotiation::AwaitingAck { context, new_holder_funding_key }) => {
+ Ok((context, new_holder_funding_key))
+ },
+ Some(FundingNegotiation::ConstructingTransaction { .. })
+ | Some(FundingNegotiation::AwaitingSignatures { .. }) => Err(ChannelError::WarnAndDisconnect(
+ format!("Got unexpected {}; funding negotiation already in progress", msg_name,),
+ )),
+ None => Err(ChannelError::Ignore(format!(
+ "Got unexpected {}; no funding negotiation in progress",
+ msg_name,
+ ))),
+ }
+ }
+
+ fn take_awaiting_ack_context(
+ &mut self, msg_name: &str,
+ ) -> Result<FundingNegotiationContext, ChannelError> {
+ match self.funding_negotiation.take() {
+ Some(FundingNegotiation::AwaitingAck { context, .. }) => Ok(context),
+ Some(other) => {
+ self.funding_negotiation = Some(other);
+ Err(ChannelError::WarnAndDisconnect(format!(
+ "Got unexpected {}; funding negotiation already in progress",
+ msg_name,
+ )))
+ },
+ None => Err(ChannelError::Ignore(format!(
+ "Got unexpected {}; no funding negotiation in progress",
+ msg_name,
+ ))),
+ }
+ }
+
fn check_get_splice_locked<SP: SignerProvider>(
&mut self, context: &ChannelContext<SP>, confirmed_funding_index: usize, height: u32,
) -> Option<msgs::SpliceLocked> {
@@ -6791,15 +6854,27 @@ where
fn reset_pending_splice_state(&mut self) -> Option<SpliceFundingFailed> {
debug_assert!(self.should_reset_pending_splice_state(true));
- debug_assert!(
- self.context.interactive_tx_signing_session.is_none()
- || !self
- .context
- .interactive_tx_signing_session
- .as_ref()
- .expect("We have a pending splice awaiting signatures")
- .has_received_commitment_signed()
- );
+
+ // Only clear the signing session if the current round is mid-signing. When an earlier
+ // round completed signing and a later RBF round is in AwaitingAck or
+ // ConstructingTransaction, the session belongs to the prior round and must be preserved.
+ let current_is_awaiting_signatures = self
+ .pending_splice
+ .as_ref()
+ .and_then(|ps| ps.funding_negotiation.as_ref())
+ .map(|fn_| matches!(fn_, FundingNegotiation::AwaitingSignatures { .. }))
+ .unwrap_or(false);
+ if current_is_awaiting_signatures {
+ debug_assert!(
+ self.context.interactive_tx_signing_session.is_none()
+ || !self
+ .context
+ .interactive_tx_signing_session
+ .as_ref()
+ .expect("We have a pending splice awaiting signatures")
+ .has_received_commitment_signed()
+ );
+ }
let splice_funding_failed = maybe_create_splice_funding_failed!(
self,
@@ -6813,7 +6888,9 @@ where
}
self.context.channel_state.clear_quiescent();
- self.context.interactive_tx_signing_session.take();
+ if current_is_awaiting_signatures {
+ self.context.interactive_tx_signing_session.take();
+ }
splice_funding_failed
}
@@ -12518,6 +12595,71 @@ where
})
}
+ fn validate_tx_ack_rbf(&self, msg: &msgs::TxAckRbf) -> Result<FundingScope, ChannelError> {
+ let pending_splice = self
+ .pending_splice
+ .as_ref()
+ .ok_or_else(|| ChannelError::Ignore("Channel is not in pending splice".to_owned()))?;
+
+ let (funding_negotiation_context, _) = pending_splice.awaiting_ack_context("tx_ack_rbf")?;
+
+ let our_funding_contribution = funding_negotiation_context.our_funding_contribution;
+ let their_funding_contribution = match msg.funding_output_contribution {
+ Some(value) => SignedAmount::from_sat(value),
+ None => SignedAmount::ZERO,
+ };
+ self.validate_splice_contributions(our_funding_contribution, their_funding_contribution)
+ .map_err(|e| ChannelError::WarnAndDisconnect(e))?;
+
+ let last_candidate = pending_splice.negotiated_candidates.last().ok_or_else(|| {
+ ChannelError::WarnAndDisconnect("No negotiated splice candidates for RBF".to_owned())
+ })?;
+ let holder_pubkeys = last_candidate.get_holder_pubkeys().clone();
+ let counterparty_funding_pubkey = *last_candidate.counterparty_funding_pubkey();
+
+ Ok(FundingScope::for_splice(
+ &self.funding,
+ &self.context,
+ our_funding_contribution,
+ their_funding_contribution,
+ counterparty_funding_pubkey,
+ holder_pubkeys,
+ ))
+ }
+
+ pub(crate) fn tx_ack_rbf<ES: EntropySource, L: Logger>(
+ &mut self, msg: &msgs::TxAckRbf, entropy_source: &ES, holder_node_id: &PublicKey,
+ logger: &L,
+ ) -> Result<Option<InteractiveTxMessageSend>, ChannelError> {
+ let rbf_funding = self.validate_tx_ack_rbf(msg)?;
+
+ log_info!(
+ logger,
+ "Starting RBF funding negotiation for channel {} after receiving tx_ack_rbf; channel value: {} sats",
+ self.context.channel_id,
+ rbf_funding.get_value_satoshis(),
+ );
+
+ let pending_splice = self
+ .pending_splice
+ .as_mut()
+ .expect("pending_splice existence validated in validate_tx_ack_rbf");
+ let funding_negotiation_context = pending_splice
+ .take_awaiting_ack_context("tx_ack_rbf")
+ .expect("awaiting ack state validated in validate_tx_ack_rbf");
+
+ let (funding_negotiation, tx_msg_opt) = FundingNegotiation::for_initiator(
+ rbf_funding,
+ &self.context,
+ funding_negotiation_context,
+ entropy_source,
+ holder_node_id,
+ );
+ pending_splice.funding_negotiation = Some(funding_negotiation);
+
+ Ok(tx_msg_opt)
+ }
+
pub(crate) fn splice_ack<ES: EntropySource, L: Logger>(
&mut self, msg: &msgs::SpliceAck, entropy_source: &ES, holder_node_id: &PublicKey,
logger: &L,
@@ -12532,36 +12674,24 @@ where
self.funding.get_value_satoshis(),
);
- let pending_splice =
- self.pending_splice.as_mut().expect("We should have returned an error earlier!");
- // TODO: Good candidate for a let else statement once MSRV >= 1.65
- let funding_negotiation_context =
- if let Some(FundingNegotiation::AwaitingAck { context, .. }) =
- pending_splice.funding_negotiation.take()
- {
- context
- } else {
- panic!("We should have returned an error earlier!");
- };
-
- let funding_feerate_sat_per_1000_weight =
- funding_negotiation_context.funding_feerate_sat_per_1000_weight;
- let (interactive_tx_constructor, tx_msg_opt) = funding_negotiation_context
- .into_interactive_tx_constructor(
- &self.context,
- &splice_funding,
- entropy_source,
- holder_node_id.clone(),
- );
- debug_assert!(tx_msg_opt.is_some());
-
debug_assert!(self.context.interactive_tx_signing_session.is_none());
- pending_splice.funding_negotiation = Some(FundingNegotiation::ConstructingTransaction {
- funding: splice_funding,
- funding_feerate_sat_per_1000_weight,
- interactive_tx_constructor,
- });
+ let pending_splice = self
+ .pending_splice
+ .as_mut()
+ .expect("pending_splice existence validated in validate_splice_ack");
+ let funding_negotiation_context = pending_splice
+ .take_awaiting_ack_context("splice_ack")
+ .expect("awaiting ack state validated in validate_splice_ack");
+
+ let (funding_negotiation, tx_msg_opt) = FundingNegotiation::for_initiator(
+ splice_funding,
+ &self.context,
+ funding_negotiation_context,
+ entropy_source,
+ holder_node_id,
+ );
+ pending_splice.funding_negotiation = Some(funding_negotiation);
Ok(tx_msg_opt)
}
@@ -12574,24 +12704,8 @@ where
.as_ref()
.ok_or_else(|| ChannelError::Ignore("Channel is not in pending splice".to_owned()))?;
- let (funding_negotiation_context, new_holder_funding_key) = match &pending_splice
- .funding_negotiation
- {
- Some(FundingNegotiation::AwaitingAck { context, new_holder_funding_key, .. }) => {
- (context, new_holder_funding_key)
- },
- Some(FundingNegotiation::ConstructingTransaction { .. })
- | Some(FundingNegotiation::AwaitingSignatures { .. }) => {
- return Err(ChannelError::WarnAndDisconnect(
- "Got unexpected splice_ack; splice negotiation already in progress".to_owned(),
- ));
- },
- None => {
- return Err(ChannelError::Ignore(
- "Got unexpected splice_ack; no splice negotiation in progress".to_owned(),
- ));
- },
- };
+ let (funding_negotiation_context, new_holder_funding_key) =
+ pending_splice.awaiting_ack_context("splice_ack")?;
let our_funding_contribution = funding_negotiation_context.our_funding_contribution;
let their_funding_contribution = SignedAmount::from_sat(msg.funding_contribution_satoshis);
diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs
index 2c416e4..330ed38 100644
--- a/lightning/src/ln/channelmanager.rs
+++ b/lightning/src/ln/channelmanager.rs
@@ -13064,6 +13064,50 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
}
}
+ fn internal_tx_ack_rbf(
+ &self, counterparty_node_id: &PublicKey, msg: &msgs::TxAckRbf,
+ ) -> Result<(), MsgHandleErrInternal> {
+ let per_peer_state = self.per_peer_state.read().unwrap();
+ let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| {
+ MsgHandleErrInternal::unreachable_no_such_peer(counterparty_node_id, msg.channel_id)
+ })?;
+ let mut peer_state_lock = peer_state_mutex.lock().unwrap();
+ let peer_state = &mut *peer_state_lock;
+
+ // Look for the channel
+ match peer_state.channel_by_id.entry(msg.channel_id) {
+ hash_map::Entry::Vacant(_) => Err(MsgHandleErrInternal::no_such_channel_for_peer(
+ counterparty_node_id,
+ msg.channel_id,
+ )),
+ hash_map::Entry::Occupied(mut chan_entry) => {
+ if let Some(ref mut funded_channel) = chan_entry.get_mut().as_funded_mut() {
+ let tx_ack_rbf_res = funded_channel.tx_ack_rbf(
+ msg,
+ &self.entropy_source,
+ &self.get_our_node_id(),
+ &self.logger,
+ );
+ let tx_msg_opt =
+ try_channel_entry!(self, peer_state, tx_ack_rbf_res, chan_entry);
+ if let Some(tx_msg) = tx_msg_opt {
+ peer_state
+ .pending_msg_events
+ .push(tx_msg.into_msg_send_event(counterparty_node_id.clone()));
+ }
+ Ok(())
+ } else {
+ try_channel_entry!(
+ self,
+ peer_state,
+ Err(ChannelError::close("Channel is not funded, cannot RBF splice".into())),
+ chan_entry
+ )
+ }
+ },
+ }
+ }
+
fn internal_splice_locked(
&self, counterparty_node_id: &PublicKey, msg: &msgs::SpliceLocked,
) -> Result<(), MsgHandleErrInternal> {
@@ -16485,11 +16529,16 @@ impl<
}
fn handle_tx_ack_rbf(&self, counterparty_node_id: PublicKey, msg: &msgs::TxAckRbf) {
- let err = Err(MsgHandleErrInternal::send_err_msg_no_close(
- "Dual-funded channels not supported".to_owned(),
- msg.channel_id.clone(),
- ));
- let _: Result<(), _> = self.handle_error(err, counterparty_node_id);
+ let _persistence_guard = PersistenceNotifierGuard::optionally_notify(self, || {
+ let res = self.internal_tx_ack_rbf(&counterparty_node_id, msg);
+ let persist = match &res {
+ Err(e) if e.closes_channel() => NotifyOption::DoPersist,
+ Err(_) => NotifyOption::SkipPersistHandleEvents,
+ Ok(()) => NotifyOption::SkipPersistHandleEvents,
+ };
+ let _ = self.handle_error(res, counterparty_node_id);
+ persist
+ });
}
fn handle_tx_abort(&self, counterparty_node_id: PublicKey, msg: &msgs::TxAbort) {
diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs
index d0fb29d..b09f5aa 100644
--- a/lightning/src/ln/splicing_tests.rs
+++ b/lightning/src/ln/splicing_tests.rs
@@ -234,6 +234,21 @@ pub fn do_initiate_splice_in<'a, 'b, 'c, 'd>(
funding_contribution
}
+pub fn do_initiate_rbf_splice_in<'a, 'b, 'c, 'd>(
+ node: &'a Node<'b, 'c, 'd>, counterparty: &'a Node<'b, 'c, 'd>, channel_id: ChannelId,
+ value_added: Amount, feerate: FeeRate,
+) -> FundingContribution {
+ let node_id_counterparty = counterparty.node.get_our_node_id();
+ let funding_template =
+ node.node.rbf_channel(&channel_id, &node_id_counterparty, feerate, FeeRate::MAX).unwrap();
+ let wallet = WalletSync::new(Arc::clone(&node.wallet_source), node.logger);
+ let funding_contribution = funding_template.splice_in_sync(value_added, &wallet).unwrap();
+ node.node
+ .funding_contributed(&channel_id, &node_id_counterparty, funding_contribution.clone(), None)
+ .unwrap();
+ funding_contribution
+}
+
pub fn initiate_splice_out<'a, 'b, 'c, 'd>(
initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, channel_id: ChannelId,
outputs: Vec<TxOut>,
@@ -312,6 +327,25 @@ pub fn complete_splice_handshake<'a, 'b, 'c, 'd>(
new_funding_script
}
+pub fn complete_rbf_handshake<'a, 'b, 'c, 'd>(
+ initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>,
+) -> msgs::TxAckRbf {
+ let node_id_initiator = initiator.node.get_our_node_id();
+ let node_id_acceptor = acceptor.node.get_our_node_id();
+
+ let stfu_init = get_event_msg!(initiator, MessageSendEvent::SendStfu, node_id_acceptor);
+ acceptor.node.handle_stfu(node_id_initiator, &stfu_init);
+ let stfu_ack = get_event_msg!(acceptor, MessageSendEvent::SendStfu, node_id_initiator);
+ initiator.node.handle_stfu(node_id_acceptor, &stfu_ack);
+
+ let tx_init_rbf = get_event_msg!(initiator, MessageSendEvent::SendTxInitRbf, node_id_acceptor);
+ acceptor.node.handle_tx_init_rbf(node_id_initiator, &tx_init_rbf);
+ let tx_ack_rbf = get_event_msg!(acceptor, MessageSendEvent::SendTxAckRbf, node_id_initiator);
+ initiator.node.handle_tx_ack_rbf(node_id_acceptor, &tx_ack_rbf);
+
+ tx_ack_rbf
+}
+
pub fn complete_interactive_funding_negotiation<'a, 'b, 'c, 'd>(
initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, channel_id: ChannelId,
initiator_contribution: FundingContribution, new_funding_script: ScriptBuf,
@@ -4095,9 +4129,10 @@ fn test_splice_acceptor_disconnect_emits_events() {
#[test]
fn test_splice_rbf_acceptor_basic() {
- // Test the happy path for accepting an RBF of a pending splice transaction.
- // After completing a splice-in, initiate an RBF attempt with a higher feerate,
- // going through the tx_init_rbf → tx_ack_rbf flow.
+ // Test the full end-to-end flow for RBF of a pending splice transaction.
+ // Complete a splice-in, then use rbf_channel API to initiate an RBF attempt
+ // with a higher feerate, going through the full tx_init_rbf → tx_ack_rbf →
+ // interactive TX → signing → mining → splice_locked flow.
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]);
@@ -4113,39 +4148,117 @@ fn test_splice_rbf_acceptor_basic() {
let added_value = Amount::from_sat(50_000);
provide_utxo_reserves(&nodes, 2, added_value * 2);
- // Complete a splice-in from node 0.
+ // Step 1: Complete a splice-in from node 0.
let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value);
- let (_splice_tx, _new_funding_script) =
+ // 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);
- // Initiate an RBF with a feerate satisfying the 25/24 rule.
- // Original feerate was FEERATE_FLOOR_SATS_PER_KW (253). 253 * 25 / 24 = 263.54, so 264 works.
+ // Step 2: Provide more UTXO reserves for the RBF attempt.
provide_utxo_reserves(&nodes, 2, added_value * 2);
+ // Step 3: Use rbf_channel API to initiate the RBF.
+ // Original feerate was FEERATE_FLOOR_SATS_PER_KW (253). 253 * 25 / 24 = 263.54, so 264 works.
let rbf_feerate_sat_per_kwu = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24);
let rbf_feerate = FeeRate::from_sat_per_kwu(rbf_feerate_sat_per_kwu);
- let funding_template =
- nodes[0].node.rbf_channel(&channel_id, &node_id_1, rbf_feerate, FeeRate::MAX).unwrap();
- let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger);
- let funding_contribution = funding_template.splice_in_sync(added_value, &wallet).unwrap();
+ let funding_contribution =
+ do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate);
- nodes[0].node.funding_contributed(&channel_id, &node_id_1, funding_contribution, None).unwrap();
+ // Steps 4-8: STFU exchange → tx_init_rbf → tx_ack_rbf.
+ complete_rbf_handshake(&nodes[0], &nodes[1]);
- let stfu_a = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1);
- nodes[1].node.handle_stfu(node_id_0, &stfu_a);
- let stfu_b = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0);
- nodes[0].node.handle_stfu(node_id_1, &stfu_b);
+ // Step 9: Complete interactive funding negotiation.
+ complete_interactive_funding_negotiation(
+ &nodes[0],
+ &nodes[1],
+ channel_id,
+ funding_contribution,
+ new_funding_script.clone(),
+ );
- 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_sat_per_kwu as u32);
+ // Step 10: Sign and broadcast.
+ let (rbf_tx, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], false);
+ assert!(splice_locked.is_none());
- 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);
+ 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);
- assert_eq!(tx_ack_rbf.channel_id, channel_id);
- // Acceptor doesn't contribute funds in the RBF.
- assert_eq!(tx_ack_rbf.funding_output_contribution, None);
+ // 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());
+ }
}
#[test]
@@ -4190,8 +4303,6 @@ fn test_splice_rbf_insufficient_feerate() {
// Acceptor-side: tx_init_rbf with an insufficient feerate is also rejected.
reenter_quiescence(&nodes[0], &nodes[1], &channel_id);
- // Send tx_init_rbf with feerate that does NOT satisfy the 25/24 rule.
- // Original feerate was 253. Using exactly 253 should fail since 253 * 24 < 253 * 25.
let tx_init_rbf = msgs::TxInitRbf {
channel_id,
locktime: 0,
@@ -4418,3 +4529,59 @@ fn test_splice_rbf_zeroconf_rejected() {
_ => panic!("Expected HandleError, got {:?}", msg_events[0]),
}
}
+
+#[test]
+fn test_splice_rbf_not_quiescence_initiator() {
+ // Test that tx_init_rbf from the non-quiescence-initiator is rejected because the
+ // quiescence initiator's RBF flow has already set funding_negotiation to AwaitingAck.
+ 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 a splice-in from node 0.
+ let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value);
+ let (_splice_tx, _new_funding_script) =
+ splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution);
+
+ // Provide more UTXO reserves for the RBF attempt.
+ provide_utxo_reserves(&nodes, 2, added_value * 2);
+
+ // Initiate RBF from node 0 (quiescence initiator).
+ let rbf_feerate_sat_per_kwu = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24);
+ let rbf_feerate = FeeRate::from_sat_per_kwu(rbf_feerate_sat_per_kwu);
+ let _funding_contribution =
+ do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate);
+
+ // STFU exchange: node 0 initiates quiescence.
+ let stfu_init = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1);
+ nodes[1].node.handle_stfu(node_id_0, &stfu_init);
+ let stfu_ack = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0);
+ nodes[0].node.handle_stfu(node_id_1, &stfu_ack);
+
+ // Node 0 sends tx_init_rbf as the quiescence initiator — grab and discard.
+ let _tx_init_rbf = get_event_msg!(nodes[0], MessageSendEvent::SendTxInitRbf, node_id_1);
+
+ // Now craft a competing tx_init_rbf from node 1 (the non-initiator).
+ let tx_init_rbf = msgs::TxInitRbf {
+ channel_id,
+ locktime: 0,
+ feerate_sat_per_1000_weight: 500,
+ funding_output_contribution: Some(added_value.to_sat() as i64),
+ };
+
+ nodes[0].node.handle_tx_init_rbf(node_id_1, &tx_init_rbf);
+
+ let tx_abort = get_event_msg!(nodes[0], MessageSendEvent::SendTxAbort, node_id_1);
+ assert_eq!(tx_abort.channel_id, channel_id);
+}
Why this scored 32/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.