Add rbf_channel API for initiating splice RBF
What changed, and why it matters
This commit adds a new public API called rbf_channel to the Lightning Dev Kit's rust-lightning library. It lets a user bump the transaction fee (via replace-by-fee, or RBF) on a pending splice funding transaction before it is locked on-chain. The change is a feature addition, not a bug fix, and includes several safety checks such as requiring a pending splice, no active funding negotiation, and a minimum 25/24 feerate increase over the previous attempt. There is no direct evidence in the commit that this fixes a security vulnerability.
Review the new API's validation logic and error handling during normal code review. No immediate security response is indicated by the commit content. Ensure documentation and tests cover edge cases such as repeated RBF attempts, tie-breaking with the counterparty, and interaction with zeroconf channels.
Security signals we found
New public API added with multiple state and feerate validation checks
Enforces 25/24 feerate increase rule required by the Lightning splicing specification
Prevents RBF after splice_locked has been sent by either side
Prevents RBF while another funding negotiation is active
No mention of vulnerability, CVE, bug fix, or security issue in commit message or diff
Evidence from the diff
The commit introduces ChannelManager::rbf_channel and Channel::rbf_channel as the entry point for initiating a splice RBF. It returns a FundingTemplate similar to splice_channel. The implementation validates state: holder commitment point exists, no pending quiescent action, channel is usable, zeroconf is not enabled, min_feerate <= max_feerate, a pending splice exists, no funding negotiation is in progress, neither side has sent splice_locked, at least one negotiated splice candidate exists, and the new feerate satisfies the BOLT spec’s 25/24 increase rule. It also updates the quiescence logic to allow initiating an RBF splice while another splice is pending if can_initiate_rbf passes. No security bug or vulnerability is described or fixed in the diff.
Changed components
lightning/src/ln/channel.rslightning/src/ln/channelmanager.rsChannelManager::rbf_channel public APIChannel::rbf_channel internal methodSplice RBF / quiescence coordination logicInspect captured patch +220 / −11
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index be8e0e1..29efe9a 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -11827,6 +11827,126 @@ where
Ok(FundingTemplate::new(Some(shared_input), min_feerate, max_feerate))
}
+ /// Initiate an RBF of a pending splice transaction.
+ pub fn rbf_channel(
+ &self, min_feerate: FeeRate, max_feerate: FeeRate,
+ ) -> Result<FundingTemplate, APIError> {
+ if self.holder_commitment_point.current_point().is_none() {
+ return Err(APIError::APIMisuseError {
+ err: format!(
+ "Channel {} cannot RBF until a payment is routed",
+ self.context.channel_id(),
+ ),
+ });
+ }
+
+ if self.quiescent_action.is_some() {
+ return Err(APIError::APIMisuseError {
+ err: format!(
+ "Channel {} cannot RBF as one is waiting to be negotiated",
+ self.context.channel_id(),
+ ),
+ });
+ }
+
+ if !self.context.is_usable() {
+ return Err(APIError::APIMisuseError {
+ err: format!(
+ "Channel {} cannot RBF as it is either pending open/close",
+ self.context.channel_id()
+ ),
+ });
+ }
+
+ if self.context.minimum_depth(&self.funding) == Some(0) {
+ return Err(APIError::APIMisuseError {
+ err: format!(
+ "Channel {} has option_zeroconf, cannot RBF splice",
+ self.context.channel_id(),
+ ),
+ });
+ }
+
+ if min_feerate > max_feerate {
+ return Err(APIError::APIMisuseError {
+ err: format!(
+ "Channel {} min_feerate {} exceeds max_feerate {}",
+ self.context.channel_id(),
+ min_feerate,
+ max_feerate,
+ ),
+ });
+ }
+
+ self.can_initiate_rbf(min_feerate).map_err(|err| APIError::APIMisuseError { err })?;
+
+ let funding_txo = self.funding.get_funding_txo().expect("funding_txo should be set");
+ let previous_utxo =
+ self.funding.get_funding_output().expect("funding_output should be set");
+ let shared_input = Input {
+ outpoint: funding_txo.into_bitcoin_outpoint(),
+ previous_utxo,
+ satisfaction_weight: EMPTY_SCRIPT_SIG_WEIGHT + FUNDING_TRANSACTION_WITNESS_WEIGHT,
+ };
+
+ Ok(FundingTemplate::new(Some(shared_input), min_feerate, max_feerate))
+ }
+
+ fn can_initiate_rbf(&self, feerate: FeeRate) -> Result<(), String> {
+ let pending_splice = match &self.pending_splice {
+ Some(pending_splice) => pending_splice,
+ None => {
+ return Err(format!(
+ "Channel {} has no pending splice to RBF",
+ self.context.channel_id(),
+ ));
+ },
+ };
+
+ if pending_splice.funding_negotiation.is_some() {
+ return Err(format!(
+ "Channel {} cannot RBF as a funding negotiation is already in progress",
+ self.context.channel_id(),
+ ));
+ }
+
+ if pending_splice.sent_funding_txid.is_some() {
+ return Err(format!(
+ "Channel {} already sent splice_locked, cannot RBF",
+ self.context.channel_id(),
+ ));
+ }
+
+ if pending_splice.received_funding_txid.is_some() {
+ return Err(format!(
+ "Channel {} counterparty already sent splice_locked, cannot RBF",
+ self.context.channel_id(),
+ ));
+ }
+
+ if pending_splice.negotiated_candidates.is_empty() {
+ return Err(format!(
+ "Channel {} has no negotiated splice candidates to RBF",
+ self.context.channel_id(),
+ ));
+ }
+
+ // Check the 25/24 feerate increase rule
+ let new_feerate = feerate.to_sat_per_kwu() as u32;
+ if let Some(prev_feerate) = pending_splice.last_funding_feerate_sat_per_1000_weight {
+ if (new_feerate as u64) * 24 < (prev_feerate as u64) * 25 {
+ return Err(format!(
+ "Channel {} RBF feerate {} is less than 25/24 of the previous feerate {}",
+ self.context.channel_id(),
+ new_feerate,
+ prev_feerate,
+ ));
+ }
+ }
+
+ Ok(())
+ }
+
pub fn funding_contributed<L: Logger>(
&mut self, contribution: FundingContribution, locktime: LockTime, logger: &L,
) -> Result<Option<msgs::Stfu>, QuiescentError> {
@@ -13353,17 +13473,18 @@ where
}
if let Some(action) = self.quiescent_action.as_ref() {
- // We can't initiate another splice while ours is pending, so don't bother becoming
- // quiescent yet.
- // TODO(splicing): Allow the splice as an RBF once supported.
- let has_splice_action = matches!(action, QuiescentAction::Splice { .. });
- if has_splice_action && self.pending_splice.is_some() {
- log_given_level!(
- logger,
- logger_level,
- "Waiting for pending splice to lock before sending stfu for new splice"
- );
- return None;
+ #[allow(irrefutable_let_patterns)]
+ if let QuiescentAction::Splice { contribution, .. } = action {
+ if self.pending_splice.is_some() {
+ if let Err(msg) = self.can_initiate_rbf(contribution.feerate()) {
+ log_given_level!(
+ logger,
+ logger_level,
+ "Waiting on sending stfu for splice RBF: {msg}"
+ );
+ return None;
+ }
+ }
}
}
diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs
index 640dc82..888e9ff 100644
--- a/lightning/src/ln/channelmanager.rs
+++ b/lightning/src/ln/channelmanager.rs
@@ -4728,6 +4728,94 @@ impl<
}
}
+ /// Initiate an RBF of a pending splice transaction for an existing channel.
+ ///
+ /// This is used after a splice has been negotiated but before it has been locked, in order
+ /// to bump the feerate of the funding transaction via replace-by-fee.
+ ///
+ /// # Required Feature Flags
+ ///
+ /// Initiating an RBF requires that the channel counterparty supports splicing. The
+ /// counterparty must be currently connected.
+ ///
+ /// # Arguments
+ ///
+ /// The RBF initiator is responsible for paying fees for common fields, shared inputs, and
+ /// shared outputs along with any contributed inputs and outputs. When building a
+ /// [`FundingContribution`], fees are estimated using `min_feerate` and must be covered by the
+ /// supplied inputs for splice-in or the channel balance for splice-out. If the counterparty
+ /// also initiates an RBF and wins the tie-break, they become the initiator and choose the
+ /// feerate. In that case, `max_feerate` is used to reject a feerate that is too high for our
+ /// contribution.
+ ///
+ /// Returns a [`FundingTemplate`] which should be used to build a [`FundingContribution`] via
+ /// one of its splice methods (e.g., [`FundingTemplate::splice_in_sync`]). The resulting
+ /// contribution must then be passed to [`ChannelManager::funding_contributed`].
+ ///
+ /// # Events
+ ///
+ /// Once the funding transaction has been constructed, an [`Event::SplicePending`] will be
+ /// emitted. At this point, any inputs contributed to the splice can only be re-spent if an
+ /// [`Event::DiscardFunding`] is seen.
+ ///
+ /// After initial signatures have been exchanged, [`Event::FundingTransactionReadyForSigning`]
+ /// will be generated and [`ChannelManager::funding_transaction_signed`] should be called.
+ ///
+ /// If any failures occur while negotiating the funding transaction, an [`Event::SpliceFailed`]
+ /// will be emitted. Any contributed inputs no longer used will be included here and thus can
+ /// be re-spent.
+ ///
+ /// Once the splice has been locked by both counterparties, an [`Event::ChannelReady`] will be
+ /// emitted with the new funding output. At this point, a new splice can be negotiated by
+ /// calling `splice_channel` again on this channel.
+ ///
+ /// [`FundingContribution`]: crate::ln::funding::FundingContribution
+ pub fn rbf_channel(
+ &self, channel_id: &ChannelId, counterparty_node_id: &PublicKey, min_feerate: FeeRate,
+ max_feerate: FeeRate,
+ ) -> Result<FundingTemplate, APIError> {
+ let per_peer_state = self.per_peer_state.read().unwrap();
+
+ let peer_state_mutex = match per_peer_state
+ .get(counterparty_node_id)
+ .ok_or_else(|| APIError::no_such_peer(counterparty_node_id))
+ {
+ Ok(p) => p,
+ Err(e) => return Err(e),
+ };
+
+ let mut peer_state = peer_state_mutex.lock().unwrap();
+ if !peer_state.latest_features.supports_splicing() {
+ return Err(APIError::ChannelUnavailable {
+ err: "Peer does not support splicing".to_owned(),
+ });
+ }
+ if !peer_state.latest_features.supports_quiescence() {
+ return Err(APIError::ChannelUnavailable {
+ err: "Peer does not support quiescence, a splicing prerequisite".to_owned(),
+ });
+ }
+
+ // Look for the channel
+ match peer_state.channel_by_id.entry(*channel_id) {
+ hash_map::Entry::Occupied(chan_phase_entry) => {
+ if let Some(chan) = chan_phase_entry.get().as_funded() {
+ chan.rbf_channel(min_feerate, max_feerate)
+ } else {
+ Err(APIError::ChannelUnavailable {
+ err: format!(
+ "Channel with id {} is not funded, cannot RBF splice",
+ channel_id
+ ),
+ })
+ }
+ },
+ hash_map::Entry::Vacant(_) => {
+ Err(APIError::no_such_channel_for_peer(channel_id, counterparty_node_id))
+ },
+ }
+ }
+
#[cfg(test)]
pub(crate) fn abandon_splice(
&self, channel_id: &ChannelId, counterparty_node_id: &PublicKey,
Why this scored 28/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.