Accept tx_init_rbf for pending splice transactions
What changed, and why it matters
This commit adds the ability for one side of a Lightning channel (the 'acceptor') to accept a fee-bump request for a pending splice transaction. Previously, such requests were rejected outright. The change is a partial feature implementation: only the acceptor side is implemented, and the code includes several safety checks to prevent misuse. There is no direct evidence in the commit that this fixes an active security vulnerability; it appears to be a protocol feature enhancement.
Review as a normal feature commit. Verify that the 25/24 rule and zero-conf rejection align with the intended protocol semantics, and that serialization changes are backward-compatible. No immediate security response appears warranted based on the supplied materials.
Security signals we found
New network message handler for tx_init_rbf replaces prior unconditional rejection
Multiple validation gates added before allowing RBF negotiation
25/24 feerate increase rule enforced
Zero-conf channels explicitly rejected for RBF splice
State serialization extended with last_funding_feerate_sat_per_1000_weight
Acceptor contribution hardcoded to SignedAmount::ZERO
Evidence from the diff
The commit implements handling of tx_init_rbf messages for pending splice transactions in rust-lightning. It adds validation in Channel::validate_tx_init_rbf and a handler Channel::tx_init_rbf, wired through ChannelManager::internal_tx_init_rbf and handle_tx_init_rbf. Safety checks include: commitment point advancement, quiescence requirement, rejection of zero-conf channels, absence of an in-progress negotiation, no already-sent/received splice_locked, existence of a prior negotiated candidate, and enforcement of the 25/24 feerate-increase rule. The acceptor contributes zero additional funds. State serialization is updated to persist the last funding feerate. Tests cover happy path and rejection cases.
Changed components
lightning/src/ln/channel.rslightning/src/ln/channelmanager.rslightning/src/ln/interactivetxs.rslightning/src/ln/splicing_tests.rsInspect captured patch +590 / −34
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index 05bd9b3..be8e0e1 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -2106,6 +2106,7 @@ where
let funding_negotiation = pending_splice.funding_negotiation.take();
if let Some(FundingNegotiation::ConstructingTransaction {
mut funding,
+ funding_feerate_sat_per_1000_weight,
interactive_tx_constructor,
}) = funding_negotiation
{
@@ -2116,6 +2117,7 @@ where
Some(FundingNegotiation::AwaitingSignatures {
is_initiator,
funding,
+ funding_feerate_sat_per_1000_weight,
initial_commitment_signed_from_counterparty: None,
});
interactive_tx_constructor
@@ -2896,6 +2898,10 @@ struct PendingFunding {
/// The funding txid used in the `splice_locked` received from the counterparty.
received_funding_txid: Option<Txid>,
+
+ /// The feerate used in the last successfully negotiated funding transaction.
+ /// Used for validating the 25/24 feerate increase rule on RBF attempts.
+ last_funding_feerate_sat_per_1000_weight: Option<u32>,
}
impl_writeable_tlv_based!(PendingFunding, {
@@ -2903,6 +2909,7 @@ impl_writeable_tlv_based!(PendingFunding, {
(3, negotiated_candidates, required_vec),
(5, sent_funding_txid, option),
(7, received_funding_txid, option),
+ (8, last_funding_feerate_sat_per_1000_weight, option),
});
#[derive(Debug)]
@@ -2913,10 +2920,12 @@ enum FundingNegotiation {
},
ConstructingTransaction {
funding: FundingScope,
+ funding_feerate_sat_per_1000_weight: u32,
interactive_tx_constructor: InteractiveTxConstructor,
},
AwaitingSignatures {
funding: FundingScope,
+ funding_feerate_sat_per_1000_weight: u32,
is_initiator: bool,
/// The initial [`msgs::CommitmentSigned`] message received for the [`FundingScope`] above.
/// We delay processing this until the user manually approves the splice via
@@ -2936,6 +2945,7 @@ impl_writeable_tlv_based_enum_upgradable!(FundingNegotiation,
(0, AwaitingSignatures) => {
(1, funding, required),
(3, is_initiator, required),
+ (5, funding_feerate_sat_per_1000_weight, (default_value, 0)),
(_unused, initial_commitment_signed_from_counterparty, (static_value, None)),
},
unread_variants: AwaitingAck, ConstructingTransaction
@@ -2959,6 +2969,37 @@ impl FundingNegotiation {
FundingNegotiation::AwaitingSignatures { is_initiator, .. } => *is_initiator,
}
}
+ fn for_acceptor<SP: SignerProvider, ES: EntropySource>(
+ funding: FundingScope, context: &ChannelContext<SP>, entropy_source: &ES,
+ holder_node_id: &PublicKey, our_funding_contribution: SignedAmount,
+ prev_funding_input: SharedOwnedInput, locktime: u32, feerate_sat_per_1000_weight: u32,
+ our_funding_inputs: Vec<FundingTxInput>, our_funding_outputs: Vec<TxOut>,
+ ) -> FundingNegotiation {
+ let funding_negotiation_context = FundingNegotiationContext {
+ is_initiator: false,
+ our_funding_contribution,
+ funding_tx_locktime: LockTime::from_consensus(locktime),
+ funding_feerate_sat_per_1000_weight: feerate_sat_per_1000_weight,
+ shared_funding_input: Some(prev_funding_input),
+ our_funding_inputs,
+ our_funding_outputs,
+ };
+
+ let (interactive_tx_constructor, first_message) = funding_negotiation_context
+ .into_interactive_tx_constructor(
+ context,
+ &funding,
+ entropy_source,
+ holder_node_id.clone(),
+ );
+ debug_assert!(first_message.is_none());
+
+ FundingNegotiation::ConstructingTransaction {
+ funding,
+ funding_feerate_sat_per_1000_weight: feerate_sat_per_1000_weight,
+ interactive_tx_constructor,
+ }
+ }
}
impl PendingFunding {
@@ -8794,10 +8835,15 @@ where
if let Some(pending_splice) = self.pending_splice.as_mut() {
self.context.channel_state.clear_quiescent();
- if let Some(FundingNegotiation::AwaitingSignatures { mut funding, .. }) =
- pending_splice.funding_negotiation.take()
+ if let Some(FundingNegotiation::AwaitingSignatures {
+ mut funding,
+ funding_feerate_sat_per_1000_weight,
+ ..
+ }) = pending_splice.funding_negotiation.take()
{
funding.funding_transaction = Some(funding_tx);
+ pending_splice.last_funding_feerate_sat_per_1000_weight =
+ Some(funding_feerate_sat_per_1000_weight);
let funding_txo =
funding.get_funding_txo().expect("funding outpoint should be set");
@@ -11904,6 +11950,7 @@ where
negotiated_candidates: vec![],
sent_funding_txid: None,
received_funding_txid: None,
+ last_funding_feerate_sat_per_1000_weight: None,
});
msgs::SpliceInit {
@@ -12096,11 +12143,9 @@ where
Ok(())
}
- pub(crate) fn splice_init<ES: EntropySource, L: Logger>(
- &mut self, msg: &msgs::SpliceInit, entropy_source: &ES, holder_node_id: &PublicKey,
- logger: &L,
- ) -> Result<msgs::SpliceAck, ChannelError> {
- let feerate = FeeRate::from_sat_per_kwu(msg.funding_feerate_per_kw as u64);
+ fn resolve_queued_contribution<L: Logger>(
+ &self, feerate: FeeRate, logger: &L,
+ ) -> (Option<SignedAmount>, Option<Amount>) {
let holder_balance = self
.get_holder_counterparty_balances_floor_incl_fee(&self.funding)
.map(|(holder, _)| holder)
@@ -12114,7 +12159,8 @@ where
);
})
.ok();
- let our_funding_contribution =
+
+ let net_value =
holder_balance.and_then(|_| self.queued_funding_contribution()).and_then(|c| {
c.net_value_for_acceptor_at_feerate(feerate, holder_balance.unwrap())
.map_err(|e| {
@@ -12130,6 +12176,17 @@ where
.ok()
});
+ (net_value, holder_balance)
+ }
+
+ pub(crate) fn splice_init<ES: EntropySource, L: Logger>(
+ &mut self, msg: &msgs::SpliceInit, entropy_source: &ES, holder_node_id: &PublicKey,
+ logger: &L,
+ ) -> Result<msgs::SpliceAck, ChannelError> {
+ let feerate = FeeRate::from_sat_per_kwu(msg.funding_feerate_per_kw as u64);
+ let (our_funding_contribution, holder_balance) =
+ self.resolve_queued_contribution(feerate, logger);
+
let splice_funding =
self.validate_splice_init(msg, our_funding_contribution.unwrap_or(SignedAmount::ZERO))?;
@@ -12152,35 +12209,26 @@ where
self.funding.get_value_satoshis(),
);
+ let new_funding_pubkey = splice_funding.get_holder_pubkeys().funding_pubkey;
let prev_funding_input = self.funding.to_splice_funding_input();
- let funding_negotiation_context = FundingNegotiationContext {
- is_initiator: false,
+ let funding_negotiation = FundingNegotiation::for_acceptor(
+ splice_funding,
+ &self.context,
+ entropy_source,
+ holder_node_id,
our_funding_contribution,
- funding_tx_locktime: LockTime::from_consensus(msg.locktime),
- funding_feerate_sat_per_1000_weight: msg.funding_feerate_per_kw,
- shared_funding_input: Some(prev_funding_input),
+ prev_funding_input,
+ msg.locktime,
+ msg.funding_feerate_per_kw,
our_funding_inputs,
our_funding_outputs,
- };
-
- let (interactive_tx_constructor, first_message) = funding_negotiation_context
- .into_interactive_tx_constructor(
- &self.context,
- &splice_funding,
- entropy_source,
- holder_node_id.clone(),
- );
- debug_assert!(first_message.is_none());
-
- let new_funding_pubkey = splice_funding.get_holder_pubkeys().funding_pubkey;
+ );
self.pending_splice = Some(PendingFunding {
- funding_negotiation: Some(FundingNegotiation::ConstructingTransaction {
- funding: splice_funding,
- interactive_tx_constructor,
- }),
+ funding_negotiation: Some(funding_negotiation),
negotiated_candidates: Vec::new(),
received_funding_txid: None,
sent_funding_txid: None,
+ last_funding_feerate_sat_per_1000_weight: None,
});
Ok(msgs::SpliceAck {
@@ -12191,6 +12239,137 @@ where
})
}
+ /// Checks during handling tx_init_rbf for an existing splice
+ fn validate_tx_init_rbf<F: FeeEstimator>(
+ &self, msg: &msgs::TxInitRbf, our_funding_contribution: SignedAmount,
+ fee_estimator: &LowerBoundedFeeEstimator<F>,
+ ) -> Result<FundingScope, ChannelError> {
+ if self.holder_commitment_point.current_point().is_none() {
+ return Err(ChannelError::WarnAndDisconnect(format!(
+ "Channel {} commitment point needs to be advanced once before RBF",
+ self.context.channel_id(),
+ )));
+ }
+
+ if !self.context.channel_state.is_quiescent() {
+ return Err(ChannelError::WarnAndDisconnect("Quiescence needed for RBF".to_owned()));
+ }
+
+ if self.context.minimum_depth(&self.funding) == Some(0) {
+ return Err(ChannelError::WarnAndDisconnect(format!(
+ "Channel {} has option_zeroconf, cannot RBF splice",
+ self.context.channel_id(),
+ )));
+ }
+
+ let pending_splice = match &self.pending_splice {
+ Some(pending_splice) => pending_splice,
+ None => {
+ return Err(ChannelError::WarnAndDisconnect(format!(
+ "Channel {} has no pending splice to RBF",
+ self.context.channel_id(),
+ )));
+ },
+ };
+
+ if pending_splice.funding_negotiation.is_some() {
+ return Err(ChannelError::Abort(AbortReason::NegotiationInProgress));
+ }
+
+ if pending_splice.received_funding_txid.is_some() {
+ return Err(ChannelError::WarnAndDisconnect(format!(
+ "Channel {} counterparty already sent splice_locked, cannot RBF",
+ self.context.channel_id(),
+ )));
+ }
+
+ if pending_splice.sent_funding_txid.is_some() {
+ return Err(ChannelError::WarnAndDisconnect(format!(
+ "Channel {} already sent splice_locked, cannot RBF",
+ self.context.channel_id(),
+ )));
+ }
+
+ let last_candidate = match pending_splice.negotiated_candidates.last() {
+ Some(candidate) => candidate,
+ None => {
+ return Err(ChannelError::WarnAndDisconnect(format!(
+ "Channel {} has no negotiated splice candidates to RBF",
+ self.context.channel_id(),
+ )));
+ },
+ };
+
+ // Check the 25/24 feerate increase rule
+ let prev_feerate =
+ pending_splice.last_funding_feerate_sat_per_1000_weight.unwrap_or_else(|| {
+ fee_estimator.bounded_sat_per_1000_weight(ConfirmationTarget::UrgentOnChainSweep)
+ });
+ let new_feerate = msg.feerate_sat_per_1000_weight;
+ if (new_feerate as u64) * 24 < (prev_feerate as u64) * 25 {
+ return Err(ChannelError::Abort(AbortReason::InsufficientRbfFeerate));
+ }
+
+ 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))?;
+
+ // Reuse funding pubkeys from the last negotiated candidate since all RBF candidates
+ // for the same splice share the same funding output script.
+ 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_init_rbf<ES: EntropySource, F: FeeEstimator, L: Logger>(
+ &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)?;
+
+ log_info!(
+ logger,
+ "Starting RBF funding negotiation for channel {} after receiving tx_init_rbf; channel value: {} sats",
+ self.context.channel_id,
+ rbf_funding.get_value_satoshis(),
+ );
+
+ let prev_funding_input = self.funding.to_splice_funding_input();
+ let funding_negotiation = FundingNegotiation::for_acceptor(
+ rbf_funding,
+ &self.context,
+ entropy_source,
+ holder_node_id,
+ our_funding_contribution,
+ prev_funding_input,
+ msg.locktime,
+ msg.feerate_sat_per_1000_weight,
+ Vec::new(),
+ Vec::new(),
+ );
+ 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,
+ })
+ }
+
pub(crate) fn splice_ack<ES: EntropySource, L: Logger>(
&mut self, msg: &msgs::SpliceAck, entropy_source: &ES, holder_node_id: &PublicKey,
logger: &L,
@@ -12217,6 +12396,8 @@ where
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,
@@ -12230,6 +12411,7 @@ where
pending_splice.funding_negotiation = Some(FundingNegotiation::ConstructingTransaction {
funding: splice_funding,
+ funding_feerate_sat_per_1000_weight,
interactive_tx_constructor,
});
diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs
index ada27af..640dc82 100644
--- a/lightning/src/ln/channelmanager.rs
+++ b/lightning/src/ln/channelmanager.rs
@@ -12877,6 +12877,53 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
}
}
+ /// Handle incoming tx_init_rbf, start a new round of interactive transaction construction.
+ fn internal_tx_init_rbf(
+ &self, counterparty_node_id: &PublicKey, msg: &msgs::TxInitRbf,
+ ) -> 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;
+
+ match peer_state.channel_by_id.entry(msg.channel_id) {
+ hash_map::Entry::Vacant(_) => {
+ return 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 init_res = funded_channel.tx_init_rbf(
+ msg,
+ &self.entropy_source,
+ &self.get_our_node_id(),
+ &self.fee_estimator,
+ &self.logger,
+ );
+ let tx_ack_rbf_msg = try_channel_entry!(self, peer_state, init_res, chan_entry);
+ peer_state.pending_msg_events.push(MessageSendEvent::SendTxAckRbf {
+ node_id: *counterparty_node_id,
+ msg: tx_ack_rbf_msg,
+ });
+ Ok(())
+ } else {
+ try_channel_entry!(
+ self,
+ peer_state,
+ Err(
+ ChannelError::close("Channel is not funded, cannot RBF splice".into(),)
+ ),
+ chan_entry
+ )
+ }
+ },
+ }
+ }
+
/// Handle incoming splice request ack, transition channel to splice-pending (unless some check fails).
fn internal_splice_ack(
&self, counterparty_node_id: &PublicKey, msg: &msgs::SpliceAck,
@@ -16330,11 +16377,16 @@ impl<
}
fn handle_tx_init_rbf(&self, counterparty_node_id: PublicKey, msg: &msgs::TxInitRbf) {
- 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_init_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_ack_rbf(&self, counterparty_node_id: PublicKey, msg: &msgs::TxAckRbf) {
diff --git a/lightning/src/ln/interactivetxs.rs b/lightning/src/ln/interactivetxs.rs
index f7e0ce3..5a9964a 100644
--- a/lightning/src/ln/interactivetxs.rs
+++ b/lightning/src/ln/interactivetxs.rs
@@ -136,6 +136,11 @@ pub(crate) enum AbortReason {
DuplicateFundingOutput,
/// More than one funding (shared) input found.
DuplicateFundingInput,
+ /// The RBF feerate is insufficient (e.g., doesn't satisfy the 25/24 rule or can't accommodate
+ /// prior contributions).
+ InsufficientRbfFeerate,
+ /// A funding negotiation is already in progress.
+ NegotiationInProgress,
/// Internal error
InternalError(&'static str),
}
@@ -195,6 +200,10 @@ impl Display for AbortReason {
f.write_str("More than one funding output found")
},
AbortReason::DuplicateFundingInput => f.write_str("More than one funding input found"),
+ AbortReason::InsufficientRbfFeerate => f.write_str("Insufficient RBF feerate"),
+ AbortReason::NegotiationInProgress => {
+ f.write_str("A funding negotiation is already in progress")
+ },
AbortReason::InternalError(text) => {
f.write_fmt(format_args!("Internal error: {}", text))
},
diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs
index f8c188c..9adc318 100644
--- a/lightning/src/ln/splicing_tests.rs
+++ b/lightning/src/ln/splicing_tests.rs
@@ -4007,6 +4007,20 @@ fn do_test_splice_pending_htlcs(config: UserConfig) {
let _ = send_payment(&nodes[1], &[&nodes[0]], 2_000 * 1000);
}
+// Returns after both sides are quiescent (no splice_init is generated since we use DoNothing).
+pub fn reenter_quiescence<'a, 'b, 'c>(
+ node_a: &Node<'a, 'b, 'c>, node_b: &Node<'a, 'b, 'c>, channel_id: &ChannelId,
+) {
+ let node_id_a = node_a.node.get_our_node_id();
+ let node_id_b = node_b.node.get_our_node_id();
+
+ node_a.node.maybe_propose_quiescence(&node_id_b, channel_id).unwrap();
+ let stfu_a = get_event_msg!(node_a, MessageSendEvent::SendStfu, node_id_b);
+ node_b.node.handle_stfu(node_id_a, &stfu_a);
+ let stfu_b = get_event_msg!(node_b, MessageSendEvent::SendStfu, node_id_a);
+ node_a.node.handle_stfu(node_id_b, &stfu_b);
+}
+
#[test]
fn test_splice_acceptor_disconnect_emits_events() {
// When both nodes contribute to a splice and the negotiation fails due to disconnect,
@@ -4078,3 +4092,302 @@ fn test_splice_acceptor_disconnect_emits_events() {
reconnect_args.send_announcement_sigs = (true, true);
reconnect_nodes(reconnect_args);
}
+
+#[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, re-enter quiescence and process tx_init_rbf
+ // from the counterparty, responding with tx_ack_rbf.
+ 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 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);
+
+ // Re-enter quiescence for RBF (node 0 initiates).
+ reenter_quiescence(&nodes[0], &nodes[1], &channel_id);
+
+ // Node 0 sends tx_init_rbf with feerate satisfying the 25/24 rule.
+ // Original feerate was FEERATE_FLOOR_SATS_PER_KW (253). 253 * 25 / 24 = 263.54, so 264 works.
+ let rbf_feerate = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24);
+ let tx_init_rbf = msgs::TxInitRbf {
+ channel_id,
+ locktime: 0,
+ feerate_sat_per_1000_weight: rbf_feerate as u32,
+ funding_output_contribution: Some(added_value.to_sat() as i64),
+ };
+
+ 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);
+ // Acceptor doesn't contribute funds in the RBF.
+ assert_eq!(tx_ack_rbf.funding_output_contribution, None);
+}
+
+#[test]
+fn test_splice_rbf_insufficient_feerate() {
+ // Test that tx_init_rbf with an insufficient feerate (less than 25/24 of previous) is rejected.
+ 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 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.
+ 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);
+
+ // Re-enter quiescence.
+ 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,
+ feerate_sat_per_1000_weight: FEERATE_FLOOR_SATS_PER_KW,
+ funding_output_contribution: Some(added_value.to_sat() as i64),
+ };
+
+ nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf);
+
+ let tx_abort = get_event_msg!(nodes[1], MessageSendEvent::SendTxAbort, node_id_0);
+ assert_eq!(tx_abort.channel_id, channel_id);
+}
+
+#[test]
+fn test_splice_rbf_no_pending_splice() {
+ // Test that tx_init_rbf is rejected when there is no pending splice to RBF.
+ 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 initial_channel_value_sat = 100_000;
+ let (_, _, channel_id, _) =
+ create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0);
+
+ // Re-enter quiescence without having done a splice.
+ reenter_quiescence(&nodes[0], &nodes[1], &channel_id);
+
+ let tx_init_rbf = msgs::TxInitRbf {
+ channel_id,
+ locktime: 0,
+ feerate_sat_per_1000_weight: 500,
+ funding_output_contribution: Some(50_000),
+ };
+
+ nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf);
+
+ let msg_events = nodes[1].node.get_and_clear_pending_msg_events();
+ assert_eq!(msg_events.len(), 1);
+ match &msg_events[0] {
+ MessageSendEvent::HandleError { action, .. } => {
+ assert_eq!(
+ *action,
+ msgs::ErrorAction::DisconnectPeerWithWarning {
+ msg: msgs::WarningMessage {
+ channel_id,
+ data: format!("Channel {} has no pending splice to RBF", channel_id),
+ },
+ }
+ );
+ },
+ _ => panic!("Expected HandleError, got {:?}", msg_events[0]),
+ }
+}
+
+#[test]
+fn test_splice_rbf_active_negotiation() {
+ // Test that tx_init_rbf is rejected when a funding negotiation is already in progress.
+ // Start a splice but don't complete interactive TX construction, then send tx_init_rbf.
+ 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 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);
+
+ // Initiate a splice but only complete the handshake (STFU + splice_init/ack),
+ // leaving interactive TX construction in progress.
+ let _funding_contribution =
+ do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value);
+ let _new_funding_script = complete_splice_handshake(&nodes[0], &nodes[1]);
+
+ // Now the acceptor (node 1) has a funding_negotiation in progress (ConstructingTransaction).
+ // Sending tx_init_rbf should be rejected.
+ 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[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf);
+
+ let tx_abort = get_event_msg!(nodes[1], MessageSendEvent::SendTxAbort, node_id_0);
+ assert_eq!(tx_abort.channel_id, channel_id);
+
+ // Clear the initiator's pending interactive TX messages from the incomplete splice handshake.
+ nodes[0].node.get_and_clear_pending_msg_events();
+}
+
+#[test]
+fn test_splice_rbf_after_splice_locked() {
+ // Test that tx_init_rbf is rejected when the counterparty has already sent splice_locked.
+ 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);
+
+ // Mine the splice tx on both nodes.
+ mine_transaction(&nodes[0], &splice_tx);
+ mine_transaction(&nodes[1], &splice_tx);
+
+ // Connect enough blocks on node 0 only so it sends splice_locked.
+ connect_blocks(&nodes[0], ANTI_REORG_DELAY - 1);
+
+ let splice_locked = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceLocked, node_id_1);
+
+ // Deliver splice_locked to node 1. Since node 1 hasn't confirmed enough blocks,
+ // it won't send its own splice_locked back, but it will set received_funding_txid.
+ nodes[1].node.handle_splice_locked(node_id_0, &splice_locked);
+
+ // Node 1 shouldn't have any messages to send (no splice_locked since it hasn't confirmed).
+ let msg_events = nodes[1].node.get_and_clear_pending_msg_events();
+ assert!(msg_events.is_empty(), "Expected no messages, got {:?}", msg_events);
+
+ // Re-enter quiescence (node 0 initiates).
+ reenter_quiescence(&nodes[0], &nodes[1], &channel_id);
+
+ // Node 0 sends tx_init_rbf, but node 0 already sent splice_locked, so it should be rejected.
+ 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[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf);
+
+ let msg_events = nodes[1].node.get_and_clear_pending_msg_events();
+ assert_eq!(msg_events.len(), 1);
+ match &msg_events[0] {
+ MessageSendEvent::HandleError { action, .. } => {
+ assert_eq!(
+ *action,
+ msgs::ErrorAction::DisconnectPeerWithWarning {
+ msg: msgs::WarningMessage {
+ channel_id,
+ data: format!(
+ "Channel {} counterparty already sent splice_locked, cannot RBF",
+ channel_id,
+ ),
+ },
+ }
+ );
+ },
+ _ => panic!("Expected HandleError, got {:?}", msg_events[0]),
+ }
+}
+
+#[test]
+fn test_splice_rbf_zeroconf_rejected() {
+ // Test that tx_init_rbf is rejected when option_zeroconf is negotiated.
+ // The zero-conf check happens before the pending_splice check, so we don't need to complete
+ // a splice — just enter quiescence and send tx_init_rbf.
+ 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_limits.trust_own_funding_0conf = true;
+ let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]);
+ let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
+
+ let node_id_0 = nodes[0].node.get_our_node_id();
+
+ let initial_channel_value_sat = 100_000;
+ let (funding_tx, channel_id) =
+ open_zero_conf_channel_with_value(&nodes[0], &nodes[1], None, initial_channel_value_sat, 0);
+ mine_transaction(&nodes[0], &funding_tx);
+ mine_transaction(&nodes[1], &funding_tx);
+
+ // Enter quiescence (node 0 initiates).
+ reenter_quiescence(&nodes[0], &nodes[1], &channel_id);
+
+ // Node 0 sends tx_init_rbf, but the channel has option_zeroconf, so it should be rejected.
+ let tx_init_rbf = msgs::TxInitRbf {
+ channel_id,
+ locktime: 0,
+ feerate_sat_per_1000_weight: 500,
+ funding_output_contribution: Some(50_000),
+ };
+
+ nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf);
+
+ let msg_events = nodes[1].node.get_and_clear_pending_msg_events();
+ assert_eq!(msg_events.len(), 1);
+ match &msg_events[0] {
+ MessageSendEvent::HandleError { action, .. } => {
+ assert_eq!(
+ *action,
+ msgs::ErrorAction::DisconnectPeerWithWarning {
+ msg: msgs::WarningMessage {
+ channel_id,
+ data: format!(
+ "Channel {} has option_zeroconf, cannot RBF splice",
+ channel_id,
+ ),
+ },
+ }
+ );
+ },
+ _ => panic!("Expected HandleError, got {:?}", msg_events[0]),
+ }
+}
Why this scored 36/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.