Merge rbf_channel into splice_channel and expose prior contribution
What changed, and why it matters
This commit is a routine API refactor in a Lightning network library. It merges two user-facing methods, `splice_channel` and `rbf_channel`, into one, and adds a way to reuse a previous funding contribution when bumping transaction fees (RBF). The changes are mostly code cleanup and convenience; they do not appear to fix an active security bug. There is one small defensive improvement: a helper now filters out inputs/outputs that are still committed to an earlier splice round when reporting a failed splice, which reduces the chance of accidentally double-spending a contribution. No independent security advisory, CVE, or researcher attribution is present in the materials.
Review as a normal API refactor. No urgent security patch is indicated. If deploying, verify that downstream callers previously using `rbf_channel` migrate to `splice_channel` and, when reusing prior contributions, check `FundingTemplate::prior_contribution()` and `min_rbf_feerate()` to avoid stale feerate constraints after a splice failure.
Security signals we found
API consolidation reduces the chance of callers choosing the wrong entry point for RBF vs fresh splice
New `splice_funding_failed_for` helper prevents previously-committed splice inputs/outputs from being included in failure events, lowering accidental double-spend/respend risk
RBF minimum feerate enforcement (25/24 of previous feerate) is preserved and centralized in `splice_channel`
Zero-conf channels continue to be rejected for RBF via `is_rbf_compatible`
No explicit security bug, CVE, or vulnerability disclosure is mentioned in the commit or supplied references
Evidence from the diff
The patch merges rbf_channel into splice_channel in lightning/src/ln/channelmanager.rs and lightning/src/ln/channel.rs, removes the standalone rbf_channel entry point, and introduces PriorContribution in lightning/src/ln/funding.rs so FundingTemplate can expose a prior contribution for RBF reuse. It adds rbf/rbf_sync methods that either adjust the prior contribution’s feerate, re-run coin selection, or build a fee-bump-only contribution. A new helper splice_funding_failed_for filters contributed inputs/outputs against pending_splice when constructing SpliceFundingFailed. Tests are updated to use the unified API and to cover zero-conf rejection, stale feerate handling, and prior-contribution adjustment paths. The commit message and diff describe this as a user-experience/API simplification, not as a security fix.
Changed components
lightning/src/ln/channel.rslightning/src/ln/channelmanager.rslightning/src/ln/funding.rslightning/src/ln/splicing_tests.rsfuzz/src/chanmon_consistency.rsfuzz/src/full_stack.rslightning/src/util/wallet_utils.rsInspect captured patch +966 / −274
diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs
index 5d46cf2..abbf473 100644
--- a/fuzz/src/chanmon_consistency.rs
+++ b/fuzz/src/chanmon_consistency.rs
@@ -1388,30 +1388,31 @@ pub fn do_test<Out: Output + MaybeSend + MaybeSync>(
}};
}
- let splice_channel = |node: &ChanMan,
- counterparty_node_id: &PublicKey,
- channel_id: &ChannelId,
- f: &dyn Fn(FundingTemplate) -> Result<FundingContribution, ()>| {
- match node.splice_channel(channel_id, counterparty_node_id) {
- Ok(funding_template) => {
- if let Ok(contribution) = f(funding_template) {
- let _ = node.funding_contributed(
- channel_id,
- counterparty_node_id,
- contribution,
- None,
+ let splice_channel =
+ |node: &ChanMan,
+ counterparty_node_id: &PublicKey,
+ channel_id: &ChannelId,
+ f: &dyn Fn(FundingTemplate) -> Result<FundingContribution, ()>| {
+ match node.splice_channel(channel_id, counterparty_node_id) {
+ Ok(funding_template) => {
+ if let Ok(contribution) = f(funding_template) {
+ let _ = node.funding_contributed(
+ channel_id,
+ counterparty_node_id,
+ contribution,
+ None,
+ );
+ }
+ },
+ Err(e) => {
+ assert!(
+ matches!(e, APIError::APIMisuseError { ref err } if err.contains("splice")),
+ "{:?}",
+ e
);
- }
- },
- Err(e) => {
- assert!(
- matches!(e, APIError::APIMisuseError { ref err } if err.contains("splice")),
- "{:?}",
- e
- );
- },
- }
- };
+ },
+ }
+ };
let splice_in =
|node: &ChanMan,
@@ -1419,10 +1420,21 @@ pub fn do_test<Out: Output + MaybeSend + MaybeSync>(
channel_id: &ChannelId,
wallet: &WalletSync<&TestWalletSource, Arc<dyn Logger + MaybeSend + MaybeSync>>,
funding_feerate_sat_per_kw: FeeRate| {
- splice_channel(node, counterparty_node_id, channel_id, &move |funding_template: FundingTemplate| {
- let feerate = funding_template.min_rbf_feerate().unwrap_or(funding_feerate_sat_per_kw);
- funding_template.splice_in_sync(Amount::from_sat(10_000), feerate, FeeRate::MAX, wallet)
- });
+ splice_channel(
+ node,
+ counterparty_node_id,
+ channel_id,
+ &move |funding_template: FundingTemplate| {
+ let feerate =
+ funding_template.min_rbf_feerate().unwrap_or(funding_feerate_sat_per_kw);
+ funding_template.splice_in_sync(
+ Amount::from_sat(10_000),
+ feerate,
+ FeeRate::MAX,
+ wallet,
+ )
+ },
+ );
};
let splice_out = |node: &ChanMan,
@@ -1444,8 +1456,7 @@ pub fn do_test<Out: Output + MaybeSend + MaybeSync>(
return;
}
splice_channel(node, counterparty_node_id, channel_id, &move |funding_template| {
- let feerate =
- funding_template.min_rbf_feerate().unwrap_or(funding_feerate_sat_per_kw);
+ let feerate = funding_template.min_rbf_feerate().unwrap_or(funding_feerate_sat_per_kw);
let outputs = vec![TxOut {
value: Amount::from_sat(MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS),
script_pubkey: wallet.get_change_script().unwrap(),
diff --git a/fuzz/src/full_stack.rs b/fuzz/src/full_stack.rs
index 9700390..f8f70fd 100644
--- a/fuzz/src/full_stack.rs
+++ b/fuzz/src/full_stack.rs
@@ -1032,8 +1032,7 @@ pub fn do_test(mut data: &[u8], logger: &Arc<dyn Logger + MaybeSend + MaybeSync>
}
let chan_id = chan.channel_id;
let counterparty = chan.counterparty.node_id;
- if let Ok(funding_template) =
- channelmanager.splice_channel(&chan_id, &counterparty)
+ if let Ok(funding_template) = channelmanager.splice_channel(&chan_id, &counterparty)
{
let feerate = funding_template
.min_rbf_feerate()
@@ -1076,8 +1075,7 @@ pub fn do_test(mut data: &[u8], logger: &Arc<dyn Logger + MaybeSend + MaybeSync>
let splice_out_sats = splice_out_sats.min(max_splice_out).max(546); // At least dust limit
let chan_id = chan.channel_id;
let counterparty = chan.counterparty.node_id;
- if let Ok(funding_template) =
- channelmanager.splice_channel(&chan_id, &counterparty)
+ if let Ok(funding_template) = channelmanager.splice_channel(&chan_id, &counterparty)
{
let feerate = funding_template
.min_rbf_feerate()
@@ -1087,9 +1085,12 @@ pub fn do_test(mut data: &[u8], logger: &Arc<dyn Logger + MaybeSend + MaybeSync>
script_pubkey: wallet.get_change_script().unwrap(),
}];
let wallet_sync = WalletSync::new(&wallet, Arc::clone(&logger));
- if let Ok(contribution) =
- funding_template.splice_out_sync(outputs, feerate, FeeRate::MAX, &wallet_sync)
- {
+ if let Ok(contribution) = funding_template.splice_out_sync(
+ outputs,
+ feerate,
+ FeeRate::MAX,
+ &wallet_sync,
+ ) {
let _ = channelmanager.funding_contributed(
&chan_id,
&counterparty,
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index 3224710..57aa83a 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -56,7 +56,7 @@ use crate::ln::channelmanager::{
MAX_LOCAL_BREAKDOWN_TIMEOUT, MIN_CLTV_EXPIRY_DELTA,
};
use crate::ln::funding::{
- FeeRateAdjustmentError, FundingContribution, FundingTemplate, FundingTxInput,
+ FeeRateAdjustmentError, FundingContribution, FundingTemplate, FundingTxInput, PriorContribution,
};
use crate::ln::interactivetxs::{
AbortReason, HandleTxCompleteValue, InteractiveTxConstructor, InteractiveTxConstructorArgs,
@@ -6790,24 +6790,30 @@ where
shutdown_result
}
+ /// Builds a [`SpliceFundingFailed`] from a contribution, filtering out inputs/outputs
+ /// that are still committed to a prior splice round.
+ fn splice_funding_failed_for(&self, contribution: FundingContribution) -> SpliceFundingFailed {
+ let (mut inputs, mut outputs) = contribution.into_contributed_inputs_and_outputs();
+ if let Some(ref pending_splice) = self.pending_splice {
+ for input in pending_splice.contributed_inputs() {
+ inputs.retain(|i| *i != input);
+ }
+ for output in pending_splice.contributed_outputs() {
+ outputs.retain(|o| o.script_pubkey != output.script_pubkey);
+ }
+ }
+ SpliceFundingFailed {
+ funding_txo: None,
+ channel_type: None,
+ contributed_inputs: inputs,
+ contributed_outputs: outputs,
+ }
+ }
+
fn quiescent_action_into_error(&self, action: QuiescentAction) -> QuiescentError {
match action {
QuiescentAction::Splice { contribution, .. } => {
- let (mut inputs, mut outputs) = contribution.into_contributed_inputs_and_outputs();
- if let Some(ref pending_splice) = self.pending_splice {
- for input in pending_splice.contributed_inputs() {
- inputs.retain(|i| *i != input);
- }
- for output in pending_splice.contributed_outputs() {
- outputs.retain(|o| o.script_pubkey != output.script_pubkey);
- }
- }
- QuiescentError::FailSplice(SpliceFundingFailed {
- funding_txo: None,
- channel_type: None,
- contributed_inputs: inputs,
- contributed_outputs: outputs,
- })
+ QuiescentError::FailSplice(self.splice_funding_failed_for(contribution))
},
#[cfg(any(test, fuzzing, feature = "_test_utils"))]
QuiescentAction::DoNothing => QuiescentError::DoNothing,
@@ -11907,7 +11913,7 @@ where
}
}
- /// Initiate splicing.
+ /// Builds a [`FundingTemplate`] for splicing or RBF, if the channel state allows it.
pub fn splice_channel(&self) -> Result<FundingTemplate, APIError> {
if self.holder_commitment_point.current_point().is_none() {
return Err(APIError::APIMisuseError {
@@ -11950,19 +11956,45 @@ where
});
}
- // Compute the RBF feerate floor from either negotiated candidates (via
- // can_initiate_rbf) or an in-progress funding negotiation (which will become a
- // negotiated candidate once it completes).
- let min_rbf_feerate = self.can_initiate_rbf().ok().flatten().or_else(|| {
- self.pending_splice
- .as_ref()
- .and_then(|pending_splice| pending_splice.funding_negotiation.as_ref())
- .map(|negotiation| {
- let prev_feerate = negotiation.funding_feerate_sat_per_1000_weight();
- let min_feerate_kwu = ((prev_feerate as u64) * 25).div_ceil(24);
- FeeRate::from_sat_per_kwu(min_feerate_kwu)
- })
- });
+ let (min_rbf_feerate, prior_contribution) = if self.is_rbf_compatible().is_err() {
+ // Channel can never RBF (e.g., zero-conf).
+ (None, None)
+ } else if let Some(pending_splice) = self.pending_splice.as_ref() {
+ // A splice is pending — either a completed negotiation that hasn't locked yet
+ // or an in-progress negotiation. In either case, the user's splice will need
+ // to satisfy the minimum RBF feerate, derived from the most recent feerate:
+ // - last_funding_feerate: from a completed but unlocked negotiation
+ // - funding_negotiation feerate: from an in-progress negotiation
+ //
+ // If the in-progress negotiation later fails (e.g., tx_abort), the derived
+ // min_rbf_feerate becomes stale, causing a slightly higher feerate than
+ // necessary. Call splice_channel again after receiving SpliceFailed to get a
+ // fresh template without the stale RBF constraint.
+ let prev_feerate =
+ pending_splice.last_funding_feerate_sat_per_1000_weight.or_else(|| {
+ pending_splice
+ .funding_negotiation
+ .as_ref()
+ .map(|n| n.funding_feerate_sat_per_1000_weight())
+ });
+ debug_assert!(
+ prev_feerate.is_some(),
+ "pending_splice should have last_funding_feerate or funding_negotiation",
+ );
+ let min_rbf_feerate = prev_feerate.map(|f| {
+ let min_feerate_kwu = ((f as u64) * 25).div_ceil(24);
+ FeeRate::from_sat_per_kwu(min_feerate_kwu)
+ });
+ let prior = if pending_splice.last_funding_feerate_sat_per_1000_weight.is_some() {
+ self.build_prior_contribution()
+ } else {
+ None
+ };
+ (min_rbf_feerate, prior)
+ } else {
+ // No pending splice — fresh splice with no RBF constraint.
+ (None, None)
+ };
let funding_txo = self.funding.get_funding_txo().expect("funding_txo should be set");
let previous_utxo =
@@ -11973,63 +12005,38 @@ where
satisfaction_weight: EMPTY_SCRIPT_SIG_WEIGHT + FUNDING_TRANSACTION_WITNESS_WEIGHT,
};
- Ok(FundingTemplate::new(Some(shared_input), min_rbf_feerate))
+ Ok(FundingTemplate::new(Some(shared_input), min_rbf_feerate, prior_contribution))
}
- /// Initiate an RBF of a pending splice transaction.
- pub fn rbf_channel(&self) -> 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()
- ),
- });
- }
+ /// Clones the prior contribution and fetches the holder balance for deferred feerate
+ /// adjustment.
+ fn build_prior_contribution(&self) -> Option<PriorContribution> {
+ debug_assert!(
+ self.pending_splice.is_some(),
+ "build_prior_contribution requires pending_splice"
+ );
+ let prior = self.pending_splice.as_ref()?.contributions.last()?;
+ let holder_balance = self
+ .get_holder_counterparty_balances_floor_incl_fee(&self.funding)
+ .map(|(h, _)| h)
+ .ok();
+ Some(PriorContribution::new(prior.clone(), holder_balance))
+ }
+ /// Returns whether this channel can ever RBF, independent of splice state.
+ fn is_rbf_compatible(&self) -> Result<(), String> {
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(),
- ),
- });
+ return Err(format!(
+ "Channel {} has option_zeroconf, cannot RBF",
+ self.context.channel_id(),
+ ));
}
-
- let min_rbf_feerate =
- self.can_initiate_rbf().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_rbf_feerate))
+ Ok(())
}
- fn can_initiate_rbf(&self) -> Result<Option<FeeRate>, String> {
+ fn can_initiate_rbf(&self) -> Result<FeeRate, String> {
+ self.is_rbf_compatible()?;
+
let pending_splice = match &self.pending_splice {
Some(pending_splice) => pending_splice,
None => {
@@ -12068,13 +12075,16 @@ where
));
}
- let min_rbf_feerate =
- pending_splice.last_funding_feerate_sat_per_1000_weight.map(|prev_feerate| {
+ match pending_splice.last_funding_feerate_sat_per_1000_weight {
+ Some(prev_feerate) => {
let min_feerate_kwu = ((prev_feerate as u64) * 25).div_ceil(24);
- FeeRate::from_sat_per_kwu(min_feerate_kwu)
- });
-
- Ok(min_rbf_feerate)
+ Ok(FeeRate::from_sat_per_kwu(min_feerate_kwu))
+ },
+ None => Err(format!(
+ "Channel {} has no prior feerate to compute RBF minimum",
+ self.context.channel_id(),
+ )),
+ }
}
/// Attempts to adjust the contribution's feerate to the minimum RBF feerate so the splice can
@@ -12104,6 +12114,9 @@ where
min_rbf_feerate,
e,
);
+ // Note: try_send_stfu prevents sending stfu until the contribution's
+ // feerate meets the minimum RBF feerate, effectively waiting for the
+ // prior splice to lock before proceeding.
return contribution;
}
@@ -12191,21 +12204,13 @@ where
}) {
log_error!(logger, "Channel {} cannot be funded: {}", self.context.channel_id(), e);
- let (contributed_inputs, contributed_outputs) =
- contribution.into_contributed_inputs_and_outputs();
-
- return Err(QuiescentError::FailSplice(SpliceFundingFailed {
- funding_txo: None,
- channel_type: None,
- contributed_inputs,
- contributed_outputs,
- }));
+ return Err(QuiescentError::FailSplice(self.splice_funding_failed_for(contribution)));
}
// If a pending splice exists with negotiated candidates, attempt to adjust the
// contribution's feerate to the minimum RBF feerate so it can proceed as an RBF immediately
// rather than waiting for the splice to lock.
- let contribution = if let Ok(Some(min_rbf_feerate)) = self.can_initiate_rbf() {
+ let contribution = if let Ok(min_rbf_feerate) = self.can_initiate_rbf() {
self.maybe_adjust_for_rbf(contribution, min_rbf_feerate, logger)
} else {
contribution
@@ -12605,12 +12610,7 @@ where
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(),
- )));
- }
+ self.is_rbf_compatible().map_err(|msg| ChannelError::WarnAndDisconnect(msg))?;
let pending_splice = match &self.pending_splice {
Some(pending_splice) => pending_splice,
@@ -13817,7 +13817,7 @@ where
);
return None;
},
- Ok(Some(min_rbf_feerate)) if contribution.feerate() < min_rbf_feerate => {
+ Ok(min_rbf_feerate) if contribution.feerate() < min_rbf_feerate => {
log_given_level!(
logger,
logger_level,
diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs
index 223d74c..a2df8bd 100644
--- a/lightning/src/ln/channelmanager.rs
+++ b/lightning/src/ln/channelmanager.rs
@@ -4701,8 +4701,7 @@ impl<
}
/// Initiate a splice in order to add value to (splice-in) or remove value from (splice-out)
- /// the channel. This will spend the channel's funding transaction output, effectively replacing
- /// it with a new one.
+ /// the channel, or to RBF a pending splice transaction.
///
/// # Required Feature Flags
///
@@ -4710,15 +4709,13 @@ impl<
/// channel (no matter the type) can be spliced, as long as the counterparty is currently
/// connected.
///
- /// Returns a [`FundingTemplate`] which should be used to build a [`FundingContribution`] via
- /// one of its splice methods (e.g., [`FundingTemplate::splice_in_sync`]). The `min_feerate`
- /// and `max_feerate` parameters are provided when calling those splice methods. The resulting
- /// contribution must then be passed to [`ChannelManager::funding_contributed`].
+ /// # Return Value
///
- /// When a pending splice exists with negotiated candidates (i.e., a splice that hasn't been
- /// locked yet), [`FundingTemplate::min_rbf_feerate`] will return the minimum feerate required
- /// for an RBF attempt (25/24 of the previous feerate). This can be used to choose an
- /// appropriate `min_feerate` when calling the splice methods.
+ /// Returns a [`FundingTemplate`] which should be used to obtain a [`FundingContribution`]
+ /// to pass to [`ChannelManager::funding_contributed`]. If a splice has been negotiated but
+ /// not yet locked, it can be replaced with a higher feerate transaction to speed up
+ /// confirmation via Replace By Fee (RBF). See [`FundingTemplate`] for details on building
+ /// a fresh contribution or reusing a prior one for RBF.
#[rustfmt::skip]
pub fn splice_channel(
&self, channel_id: &ChannelId, counterparty_node_id: &PublicKey,
@@ -4765,67 +4762,6 @@ 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.
- ///
- /// Returns a [`FundingTemplate`] which should be used to build a [`FundingContribution`] via
- /// one of its splice methods (e.g., [`FundingTemplate::splice_in_sync`]). The `min_feerate`
- /// and `max_feerate` parameters are provided when calling those splice methods.
- /// [`FundingTemplate::min_rbf_feerate`] returns the minimum feerate required for the RBF
- /// (25/24 of the previous feerate). The resulting contribution must then be passed to
- /// [`ChannelManager::funding_contributed`].
- pub fn rbf_channel(
- &self, channel_id: &ChannelId, counterparty_node_id: &PublicKey,
- ) -> 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()
- } 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,
@@ -6590,13 +6526,16 @@ impl<
///
/// If any failures occur while negotiating the funding transaction, an [`Event::SpliceFailed`]
/// will be emitted. Any contributed inputs no longer used will be included in an
- /// [`Event::DiscardFunding`] and thus can be re-spent.
+ /// [`Event::DiscardFunding`] and thus can be re-spent. If a [`FundingTemplate`] was obtained
+ /// while a previous splice was still being negotiated, its
+ /// [`min_rbf_feerate`][FundingTemplate::min_rbf_feerate] may be stale after the failure.
+ /// Call [`ChannelManager::splice_channel`] again to get a fresh template.
///
/// After initial signatures have been exchanged, [`Event::FundingTransactionReadyForSigning`]
/// will be generated and [`ChannelManager::funding_transaction_signed`] should be called.
///
/// 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
+ /// emitted with the new funding output. At this point, a new (non-RBF) splice can be negotiated by
/// calling [`ChannelManager::splice_channel`] again on this channel.
///
/// # Errors
diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs
index acad13c..52562fc 100644
--- a/lightning/src/ln/funding.rs
+++ b/lightning/src/ln/funding.rs
@@ -106,12 +106,58 @@ impl core::fmt::Display for FeeRateAdjustmentError {
}
}
+/// The user's prior contribution from a previous splice negotiation on this channel.
+///
+/// When a pending splice exists with negotiated candidates, the prior contribution is
+/// available for reuse (e.g., to bump the feerate via RBF). Contains the raw contribution and
+/// the holder's balance for deferred feerate adjustment in [`FundingTemplate::rbf_sync`] or
+/// [`FundingTemplate::rbf`].
+///
+/// Use [`FundingTemplate::prior_contribution`] to inspect the prior contribution before
+/// deciding whether to call [`FundingTemplate::rbf_sync`] or one of the splice methods
+/// with different parameters.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(super) struct PriorContribution {
+ contribution: FundingContribution,
+ /// The holder's balance, used for feerate adjustment. `None` when the balance computation
+ /// fails, in which case adjustment is skipped and coin selection is re-run.
+ holder_balance: Option<Amount>,
+}
+
+impl PriorContribution {
+ pub(super) fn new(contribution: FundingContribution, holder_balance: Option<Amount>) -> Self {
+ Self { contribution, holder_balance }
+ }
+}
+
/// A template for contributing to a channel's splice funding transaction.
///
/// This is returned from [`ChannelManager::splice_channel`] when a channel is ready to be
-/// spliced. It must be converted to a [`FundingContribution`] using one of the splice methods
-/// and passed to [`ChannelManager::funding_contributed`] in order to resume the splicing
-/// process.
+/// spliced. A [`FundingContribution`] must be obtained from it and passed to
+/// [`ChannelManager::funding_contributed`] in order to resume the splicing process.
+///
+/// # Building a Contribution
+///
+/// For a fresh splice (no pending splice to replace), build a new contribution using one of
+/// the splice methods:
+/// - [`FundingTemplate::splice_in_sync`] to add funds to the channel
+/// - [`FundingTemplate::splice_out_sync`] to remove funds from the channel
+/// - [`FundingTemplate::splice_in_and_out_sync`] to do both
+///
+/// These perform coin selection and require `min_feerate` and `max_feerate` parameters.
+///
+/// # Replace By Fee (RBF)
+///
+/// When a pending splice exists that hasn't been locked yet, use [`FundingTemplate::rbf_sync`]
+/// (or [`FundingTemplate::rbf`] for async) to build an RBF contribution. This handles the
+/// prior contribution logic internally — reusing an adjusted prior when possible, re-running
+/// coin selection when needed, or creating a fee-bump-only contribution.
+///
+/// Check [`FundingTemplate::min_rbf_feerate`] for the minimum feerate required (25/24 of
+/// the previous feerate). Use [`FundingTemplate::prior_contribution`] to inspect the prior
+/// contribution's parameters (e.g., [`FundingContribution::value_added`],
+/// [`FundingContribution::outputs`]) before deciding whether to reuse it via the RBF methods
+/// or build a fresh contribution with different parameters using the splice methods above.
///
/// [`ChannelManager::splice_channel`]: crate::ln::channelmanager::ChannelManager::splice_channel
/// [`ChannelManager::funding_contributed`]: crate::ln::channelmanager::ChannelManager::funding_contributed
@@ -124,12 +170,18 @@ pub struct FundingTemplate {
/// The minimum RBF feerate (25/24 of the previous feerate), if this template is for an
/// RBF attempt. `None` for fresh splices with no pending splice candidates.
min_rbf_feerate: Option<FeeRate>,
+
+ /// The user's prior contribution from a previous splice negotiation, if available.
+ prior_contribution: Option<PriorContribution>,
}
impl FundingTemplate {
/// Constructs a [`FundingTemplate`] for a splice using the provided shared input.
- pub(super) fn new(shared_input: Option<Input>, min_rbf_feerate: Option<FeeRate>) -> Self {
- Self { shared_input, min_rbf_feerate }
+ pub(super) fn new(
+ shared_input: Option<Input>, min_rbf_feerate: Option<FeeRate>,
+ prior_contribution: Option<PriorContribution>,
+ ) -> Self {
+ Self { shared_input, min_rbf_feerate, prior_contribution }
}
/// Returns the minimum RBF feerate, if this template is for an RBF attempt.
@@ -139,16 +191,34 @@ impl FundingTemplate {
pub fn min_rbf_feerate(&self) -> Option<FeeRate> {
self.min_rbf_feerate
}
+
+ /// Returns a reference to the prior contribution from a previous splice negotiation, if
+ /// available.
+ ///
+ /// Use this to inspect the prior contribution's parameters (e.g.,
+ /// [`FundingContribution::value_added`], [`FundingContribution::outputs`]) before deciding
+ /// whether to reuse it via [`FundingTemplate::rbf_sync`] or build a fresh contribution
+ /// with different parameters using the splice methods.
+ ///
+ /// Note: the returned contribution may reflect a different feerate than originally provided,
+ /// as it may have been adjusted for RBF or for the counterparty's feerate when acting as
+ /// the acceptor. This can change other parameters too (e.g.,
+ /// [`FundingContribution::value_added`] may be higher if the change output was removed to
+ /// cover a higher fee).
+ pub fn prior_contribution(&self) -> Option<&FundingContribution> {
+ self.prior_contribution.as_ref().map(|p| &p.contribution)
+ }
}
macro_rules! build_funding_contribution {
- ($value_added:expr, $outputs:expr, $shared_input:expr, $min_rbf_feerate:expr, $feerate:expr, $max_feerate:expr, $wallet:ident, $($await:tt)*) => {{
+ ($value_added:expr, $outputs:expr, $shared_input:expr, $min_rbf_feerate:expr, $feerate:expr, $max_feerate:expr, $force_coin_selection:expr, $wallet:ident, $($await:tt)*) => {{
let value_added: Amount = $value_added;
let outputs: Vec<TxOut> = $outputs;
let shared_input: Option<Input> = $shared_input;
let min_rbf_feerate: Option<FeeRate> = $min_rbf_feerate;
let feerate: FeeRate = $feerate;
let max_feerate: FeeRate = $max_feerate;
+ let force_coin_selection: bool = $force_coin_selection;
if feerate > max_feerate {
return Err(());
@@ -178,7 +248,7 @@ macro_rules! build_funding_contribution {
let is_splice = shared_input.is_some();
- let coin_selection = if value_added == Amount::ZERO {
+ let coin_selection = if value_added == Amount::ZERO && !force_coin_selection {
CoinSelection { confirmed_utxos: vec![], change_output: None }
} else {
// Used for creating a redeem script for the new funding txo, since the funding pubkeys
@@ -237,25 +307,32 @@ macro_rules! build_funding_contribution {
impl FundingTemplate {
/// Creates a [`FundingContribution`] for adding funds to a channel using `wallet` to perform
/// coin selection.
+ ///
+ /// `value_added` is the total amount to add to the channel for this contribution. When
+ /// replacing a prior contribution via RBF, use [`FundingTemplate::prior_contribution`] to
+ /// inspect the prior parameters. To add funds on top of the prior contribution's amount,
+ /// combine them: `prior.value_added() + additional_amount`.
pub async fn splice_in<W: CoinSelectionSource + MaybeSend>(
self, value_added: Amount, min_feerate: FeeRate, max_feerate: FeeRate, wallet: W,
) -> Result<FundingContribution, ()> {
if value_added == Amount::ZERO {
return Err(());
}
- let FundingTemplate { shared_input, min_rbf_feerate } = self;
- build_funding_contribution!(value_added, vec![], shared_input, min_rbf_feerate, min_feerate, max_feerate, wallet, await)
+ let FundingTemplate { shared_input, min_rbf_feerate, .. } = self;
+ build_funding_contribution!(value_added, vec![], shared_input, min_rbf_feerate, min_feerate, max_feerate, false, wallet, await)
}
/// Creates a [`FundingContribution`] for adding funds to a channel using `wallet` to perform
/// coin selection.
+ ///
+ /// See [`FundingTemplate::splice_in`] for details.
pub fn splice_in_sync<W: CoinSelectionSourceSync>(
self, value_added: Amount, min_feerate: FeeRate, max_feerate: FeeRate, wallet: W,
) -> Result<FundingContribution, ()> {
if value_added == Amount::ZERO {
return Err(());
}
- let FundingTemplate { shared_input, min_rbf_feerate } = self;
+ let FundingTemplate { shared_input, min_rbf_feerate, .. } = self;
build_funding_contribution!(
value_added,
vec![],
@@ -263,31 +340,39 @@ impl FundingTemplate {
min_rbf_feerate,
min_feerate,
max_feerate,
+ false,
wallet,
)
}
/// Creates a [`FundingContribution`] for removing funds from a channel using `wallet` to
/// perform coin selection.
+ ///
+ /// `outputs` are the complete set of withdrawal outputs for this contribution. When
+ /// replacing a prior contribution via RBF, use [`FundingTemplate::prior_contribution`] to
+ /// inspect the prior parameters. To keep existing withdrawals and add new ones, include the
+ /// prior's outputs: combine [`FundingContribution::outputs`] with the new outputs.
pub async fn splice_out<W: CoinSelectionSource + MaybeSend>(
self, outputs: Vec<TxOut>, min_feerate: FeeRate, max_feerate: FeeRate, wallet: W,
) -> Result<FundingContribution, ()> {
if outputs.is_empty() {
return Err(());
}
- let FundingTemplate { shared_input, min_rbf_feerate } = self;
- build_funding_contribution!(Amount::ZERO, outputs, shared_input, min_rbf_feerate, min_feerate, max_feerate, wallet, await)
+ let FundingTemplate { shared_input, min_rbf_feerate, .. } = self;
+ build_funding_contribution!(Amount::ZERO, outputs, shared_input, min_rbf_feerate, min_feerate, max_feerate, false, wallet, await)
}
/// Creates a [`FundingContribution`] for removing funds from a channel using `wallet` to
/// perform coin selection.
+ ///
+ /// See [`FundingTemplate::splice_out`] for details.
pub fn splice_out_sync<W: CoinSelectionSourceSync>(
self, outputs: Vec<TxOut>, min_feerate: FeeRate, max_feerate: FeeRate, wallet: W,
) -> Result<FundingContribution, ()> {
if outputs.is_empty() {
return Err(());
}
- let FundingTemplate { shared_input, min_rbf_feerate } = self;
+ let FundingTemplate { shared_input, min_rbf_feerate, .. } = self;
build_funding_contribution!(
Amount::ZERO,
outputs,
@@ -295,12 +380,18 @@ impl FundingTemplate {
min_rbf_feerate,
min_feerate,
max_feerate,
+ false,
wallet,
)
}
/// Creates a [`FundingContribution`] for both adding and removing funds from a channel using
/// `wallet` to perform coin selection.
+ ///
+ /// `value_added` and `outputs` are the complete parameters for this contribution, not
+ /// increments on top of a prior contribution. When replacing a prior contribution via RBF,
+ /// use [`FundingTemplate::prior_contribution`] to inspect the prior parameters and combine
+ /// them as needed.
pub async fn splice_in_and_out<W: CoinSelectionSource + MaybeSend>(
self, value_added: Amount, outputs: Vec<TxOut>, min_feerate: FeeRate, max_feerate: FeeRate,
wallet: W,
@@ -308,12 +399,14 @@ impl FundingTemplate {
if value_added == Amount::ZERO && outputs.is_empty() {
return Err(());
}
- let FundingTemplate { shared_input, min_rbf_feerate } = self;
- build_funding_contribution!(value_added, outputs, shared_input, min_rbf_feerate, min_feerate, max_feerate, wallet, await)
+ let FundingTemplate { shared_input, min_rbf_feerate, .. } = self;
+ build_funding_contribution!(value_added, outputs, shared_input, min_rbf_feerate, min_feerate, max_feerate, false, wallet, await)
}
/// Creates a [`FundingContribution`] for both adding and removing funds from a channel using
/// `wallet` to perform coin selection.
+ ///
+ /// See [`FundingTemplate::splice_in_and_out`] for details.
pub fn splice_in_and_out_sync<W: CoinSelectionSourceSync>(
self, value_added: Amount, outputs: Vec<TxOut>, min_feerate: FeeRate, max_feerate: FeeRate,
wallet: W,
@@ -321,7 +414,7 @@ impl FundingTemplate {
if value_added == Amount::ZERO && outputs.is_empty() {
return Err(());
}
- let FundingTemplate { shared_input, min_rbf_feerate } = self;
+ let FundingTemplate { shared_input, min_rbf_feerate, .. } = self;
build_funding_contribution!(
value_added,
outputs,
@@ -329,9 +422,122 @@ impl FundingTemplate {
min_rbf_feerate,
min_feerate,
max_feerate,
+ false,
wallet,
)
}
+
+ /// Creates a [`FundingContribution`] for an RBF (Replace-By-Fee) attempt on a pending splice.
+ ///
+ /// `max_feerate` is the maximum feerate the caller is willing to accept as acceptor. It is
+ /// used as the returned contribution's `max_feerate` and also constrains coin selection when
+ /// re-running it for prior contributions that cannot be adjusted or fee-bump-only
+ /// contributions.
+ ///
+ /// This handles the prior contribution logic internally:
+ /// - If the prior contribution's feerate can be adjusted to the minimum RBF feerate, the
+ /// adjusted contribution is returned directly. For splice-in, the change output absorbs
+ /// the fee difference. For splice-out (no wallet inputs), the holder's channel balance
+ /// covers the higher fees.
+ /// - If adjustment fails, coin selection is re-run using the prior contribution's
+ /// parameters and the caller's `max_feerate`. For splice-out contributions, this changes
+ /// the fee source: wallet inputs are selected to cover fees instead of deducting them
+ /// from the channel balance.
+ /// - If no prior contribution exists, coin selection is run for a fee-bump-only contribution
+ /// (`value_added = 0`), covering fees for the common fields and shared input/output via
+ /// a newly selected input. Check [`FundingTemplate::prior_contribution`] to see if this
+ /// is intended.
+ ///
+ /// Returns `Err(())` if this is not an RBF scenario ([`FundingTemplate::min_rbf_feerate`]
+ /// is `None`) or if `max_feerate` is below the minimum RBF feerate.
+ pub async fn rbf<W: CoinSelectionSource + MaybeSend>(
+ self, max_feerate: FeeRate, wallet: W,
+ ) -> Result<FundingContribution, ()> {
+ let FundingTemplate { shared_input, min_rbf_feerate, prior_contribution } = self;
+ let rbf_feerate = min_rbf_feerate.ok_or(())?;
+ if rbf_feerate > max_feerate {
+ return Err(());
+ }
+
+ match prior_contribution {
+ Some(PriorContribution { contribution, holder_balance }) => {
+ // Try to adjust the prior contribution to the RBF feerate. This fails if
+ // the holder balance can't cover the adjustment (splice-out) or the fee
+ // buffer is insufficient (splice-in), or if the prior's feerate is already
+ // above rbf_feerate (e.g., from a counterparty-initiated RBF that locked
+ // at a higher feerate). In all cases, fall through to re-run coin selection.
+ if let Some(holder_balance) = holder_balance {
+ if contribution
+ .net_value_for_initiator_at_feerate(rbf_feerate, holder_balance)
+ .is_ok()
+ {
+ let mut adjusted = contribution
+ .for_initiator_at_feerate(rbf_feerate, holder_balance)
+ .expect("feerate compatibility already checked");
+ adjusted.max_feerate = max_feerate;
+ return Ok(adjusted);
+ }
+ }
+ build_funding_contribution!(contribution.value_added, contribution.outputs, shared_input, min_rbf_feerate, rbf_feerate, max_feerate, true, wallet, await)
+ },
+ None => {
+ build_funding_contribution!(Amount::ZERO, vec![], shared_input, min_rbf_feerate, rbf_feerate, max_feerate, true, wallet, await)
+ },
+ }
+ }
+
+ /// Creates a [`FundingContribution`] for an RBF (Replace-By-Fee) attempt on a pending splice.
+ ///
+ /// See [`FundingTemplate::rbf`] for details.
+ pub fn rbf_sync<W: CoinSelectionSourceSync>(
+ self, max_feerate: FeeRate, wallet: W,
+ ) -> Result<FundingContribution, ()> {
+ let FundingTemplate { shared_input, min_rbf_feerate, prior_contribution } = self;
+ let rbf_feerate = min_rbf_feerate.ok_or(())?;
+ if rbf_feerate > max_feerate {
+ return Err(());
+ }
+
+ match prior_contribution {
+ Some(PriorContribution { contribution, holder_balance }) => {
+ // See comment in `rbf` for details on when this adjustment fails.
+ if let Some(holder_balance) = holder_balance {
+ if contribution
+ .net_value_for_initiator_at_feerate(rbf_feerate, holder_balance)
+ .is_ok()
+ {
+ let mut adjusted = contribution
+ .for_initiator_at_feerate(rbf_feerate, holder_balance)
+ .expect("feerate compatibility already checked");
+ adjusted.max_feerate = max_feerate;
+ return Ok(adjusted);
+ }
+ }
+ build_funding_contribution!(
+ contribution.value_added,
+ contribution.outputs,
+ shared_input,
+ min_rbf_feerate,
+ rbf_feerate,
+ max_feerate,
+ true,
+ wallet,
+ )
+ },
+ None => {
+ build_funding_contribution!(
+ Amount::ZERO,
+ vec![],
+ shared_input,
+ min_rbf_feerate,
+ rbf_feerate,
+ max_feerate,
+ true,
+ wallet,
+ )
+ },
+ }
+ }
}
fn estimate_transaction_fee(
@@ -385,7 +591,7 @@ fn estimate_transaction_fee(
}
/// The components of a funding transaction contributed by one party.
-#[derive(Debug, Clone)]
+#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FundingContribution {
/// The amount to contribute to the channel.
///
@@ -445,6 +651,18 @@ impl FundingContribution {
self.outputs.iter().chain(self.change_output.iter())
}
+ /// Returns the amount added to the channel by this contribution.
+ pub fn value_added(&self) -> Amount {
+ self.value_added
+ }
+
+ /// Returns the outputs (e.g., withdrawal destinations) included in this contribution.
+ ///
+ /// This does not include the change output; see [`FundingContribution::change_output`].
+ pub fn outputs(&self) -> &[TxOut] {
+ &self.outputs
+ }
+
/// Returns the change output included in this contribution, if any.
///
/// When coin selection provides more value than needed for the funding contribution and fees,
@@ -727,8 +945,8 @@ impl FundingContribution {
/// Adjusts the contribution's change output for the minimum RBF feerate.
///
/// When a pending splice exists with negotiated candidates and the contribution's feerate
- /// is below the minimum RBF feerate (25/24 of the previous feerate), this adjusts the change output
- /// so the initiator pays fees at the minimum RBF feerate.
+ /// is below the minimum RBF feerate (25/24 of the previous feerate), this adjusts the
+ /// change output so the initiator pays fees at the minimum RBF feerate.
pub(super) fn for_initiator_at_feerate(
self, feerate: FeeRate, holder_balance: Amount,
) -> Result<Self, FeeRateAdjustmentError> {
@@ -833,7 +1051,7 @@ pub type FundingTxInput = crate::util::wallet_utils::ConfirmedUtxo;
mod tests {
use super::{
estimate_transaction_fee, FeeRateAdjustmentError, FundingContribution, FundingTemplate,
- FundingTxInput,
+ FundingTxInput, PriorContribution,
};
use crate::chain::ClaimId;
use crate::util::wallet_utils::{CoinSelection, CoinSelectionSourceSync, Input};
@@ -1142,7 +1360,7 @@ mod tests {
// splice_in_sync with value_added > MAX_MONEY
{
- let template = FundingTemplate::new(None, None);
+ let template = FundingTemplate::new(None, None, None);
assert!(template
.splice_in_sync(over_max, feerate, feerate, UnreachableWallet)
.is_err());
@@ -1150,7 +1368,7 @@ mod tests {
// splice_out_sync with single output value > MAX_MONEY
{
- let template = FundingTemplate::new(None, None);
+ let template = FundingTemplate::new(None, None, None);
let outputs = vec![funding_output_sats(over_max.to_sat())];
assert!(template
.splice_out_sync(outputs, feerate, feerate, UnreachableWallet)
@@ -1159,7 +1377,7 @@ mod tests {
// splice_out_sync with multiple outputs summing > MAX_MONEY
{
- let template = FundingTemplate::new(None, None);
+ let template = FundingTemplate::new(None, None, None);
let half_over = Amount::MAX_MONEY / 2 + Amount::from_sat(1);
let outputs = vec![
funding_output_sats(half_over.to_sat()),
@@ -1172,7 +1390,7 @@ mod tests {
// splice_in_and_out_sync with value_added > MAX_MONEY
{
- let template = FundingTemplate::new(None, None);
+ let template = FundingTemplate::new(None, None, None);
let outputs = vec![funding_output_sats(1_000)];
assert!(template
.splice_in_and_out_sync(over_max, outputs, feerate, feerate, UnreachableWallet)
@@ -1181,7 +1399,7 @@ mod tests {
// splice_in_and_out_sync with output sum > MAX_MONEY
{
- let template = FundingTemplate::new(None, None);
+ let template = FundingTemplate::new(None, None, None);
let outputs = vec![funding_output_sats(over_max.to_sat())];
assert!(template
.splice_in_and_out_sync(
@@ -1202,7 +1420,7 @@ mod tests {
// min_feerate > max_feerate is rejected
{
- let template = FundingTemplate::new(None, None);
+ let template = FundingTemplate::new(None, None, None);
assert!(template
.splice_in_sync(Amount::from_sat(10_000), high, low, UnreachableWallet)
.is_err());
@@ -1210,7 +1428,7 @@ mod tests {
// min_feerate < min_rbf_feerate is rejected
{
- let template = FundingTemplate::new(None, Some(high));
+ let template = FundingTemplate::new(None, Some(high), None);
assert!(template
.splice_in_sync(Amount::from_sat(10_000), low, FeeRate::MAX, UnreachableWallet)
.is_err());
@@ -1947,4 +2165,218 @@ mod tests {
assert_eq!(initiator.feerate, target_feerate);
assert_eq!(acceptor.feerate, target_feerate);
}
+
+ #[test]
+ fn test_rbf_sync_rejects_max_feerate_below_min_rbf_feerate() {
+ // When the caller's max_feerate is below the minimum RBF feerate, rbf_sync should
+ // return Err(()).
+ let prior_feerate = FeeRate::from_sat_per_kwu(2000);
+ let min_rbf_feerate = FeeRate::from_sat_per_kwu(5000);
+ let max_feerate = FeeRate::from_sat_per_kwu(3000);
+
+ let prior = FundingContribution {
+ value_added: Amount::from_sat(50_000),
+ estimated_fee: Amount::from_sat(1_000),
+ inputs: vec![funding_input_sats(100_000)],
+ outputs: vec![],
+ change_output: None,
+ feerate: prior_feerate,
+ max_feerate: FeeRate::MAX,
+ is_splice: true,
+ };
+
+ // max_feerate (3000) < min_rbf_feerate (5000).
+ let template = FundingTemplate::new(
+ None,
+ Some(min_rbf_feerate),
+ Some(PriorContribution::new(prior, None)),
+ );
+ assert!(template.rbf_sync(max_feerate, UnreachableWallet).is_err());
+ }
+
+ #[test]
+ fn test_rbf_sync_adjusts_prior_to_rbf_feerate() {
+ // When the prior contribution's feerate is below the minimum RBF feerate and holder
+ // balance is available, rbf_sync should adjust the prior to the RBF feerate.
+ let prior_feerate = FeeRate::from_sat_per_kwu(2000);
+ let min_rbf_feerate = FeeRate::from_sat_per_kwu(2025);
+ let max_feerate = FeeRate::from_sat_per_kwu(5000);
+
+ let inputs = vec![funding_input_sats(100_000)];
+ let change = funding_output_sats(10_000);
+ let estimated_fee =
+ estimate_transaction_fee(&inputs, &[], Some(&change), true, true, prior_feerate);
+
+ let prior = FundingContribution {
+ value_added: Amount::from_sat(50_000),
+ estimated_fee,
+ inputs,
+ outputs: vec![],
+ change_output: Some(change),
+ feerate: prior_feerate,
+ max_feerate: FeeRate::MAX,
+ is_splice: true,
+ };
+
+ let template = FundingTemplate::new(
+ None,
+ Some(min_rbf_feerate),
+ Some(PriorContribution::new(prior, Some(Amount::MAX))),
+ );
+ let contribution = template.rbf_sync(max_feerate, UnreachableWallet).unwrap();
+ assert_eq!(contribution.feerate, min_rbf_feerate);
+ assert_eq!(contribution.max_feerate, max_feerate);
+ }
+
+ /// A mock wallet that returns a single UTXO for coin selection.
+ struct SingleUtxoWallet {
+ utxo: FundingTxInput,
+ change_output: Option<TxOut>,
+ }
+
+ impl CoinSelectionSourceSync for SingleUtxoWallet {
+ fn select_confirmed_utxos(
+ &self, _claim_id: Option<ClaimId>, _must_spend: Vec<Input>, _must_pay_to: &[TxOut],
+ _target_feerate_sat_per_1000_weight: u32, _max_tx_weight: u64,
+ ) -> Result<CoinSelection, ()> {
+ Ok(CoinSelection {
+ confirmed_utxos: vec![self.utxo.clone()],
+ change_output: self.change_output.clone(),
+ })
+ }
+ fn sign_psbt(&self, _psbt: Psbt) -> Result<Transaction, ()> {
+ unreachable!("should not reach signing")
+ }
+ }
+
+ fn shared_input(value_sats: u64) -> Input {
+ Input {
+ outpoint: bitcoin::OutPoint::null(),
+ previous_utxo: TxOut {
+ value: Amount::from_sat(value_sats),
+ script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()),
+ },
+ satisfaction_weight: 107,
+ }
+ }
+
+ #[test]
+ fn test_rbf_sync_unadjusted_splice_out_runs_coin_selection() {
+ // When the prior contribution's feerate is below the minimum RBF feerate and no
+ // holder balance is available, rbf_sync should run coin selection to add inputs that
+ // cover the higher RBF fee.
+ let min_rbf_feerate = FeeRate::from_sat_per_kwu(5000);
+ let prior_feerate = FeeRate::from_sat_per_kwu(2000);
+ let withdrawal = funding_output_sats(20_000);
+
+ let prior = FundingContribution {
+ value_added: Amount::ZERO,
+ estimated_fee: Amount::from_sat(500),
+ inputs: vec![],
+ outputs: vec![withdrawal.clone()],
+ change_output: None,
+ feerate: prior_feerate,
+ max_feerate: prior_feerate,
+ is_splice: true,
+ };
+
+ let template = FundingTemplate::new(
+ Some(shared_input(100_000)),
+ Some(min_rbf_feerate),
+ Some(PriorContribution::new(prior, None)),
+ );
+
+ let wallet = SingleUtxoWallet {
+ utxo: funding_input_sats(50_000),
+ change_output: Some(funding_output_sats(40_000)),
+ };
+
+ // rbf_sync should succeed and the contribution should have inputs from coin selection.
+ let contribution = template.rbf_sync(FeeRate::MAX, &wallet).unwrap();
+ assert_eq!(contribution.value_added, Amount::ZERO);
+ assert!(!contribution.inputs.is_empty(), "coin selection should have added inputs");
+ assert_eq!(contribution.outputs, vec![withdrawal]);
+ assert_eq!(contribution.feerate, min_rbf_feerate);
+ }
+
+ #[test]
+ fn test_rbf_sync_no_prior_fee_bump_only_runs_coin_selection() {
+ // When there is no prior contribution (e.g., acceptor), rbf_sync should run coin
+ // selection to add inputs for a fee-bump-only contribution.
+ let min_rbf_feerate = FeeRate::from_sat_per_kwu(5000);
+
+ let template =
+ FundingTemplate::new(Some(shared_input(100_000)), Some(min_rbf_feerate), None);
+
+ let wallet = SingleUtxoWallet {
+ utxo: funding_input_sats(50_000),
+ change_output: Some(funding_output_sats(45_000)),
+ };
+
+ let contribution = template.rbf_sync(FeeRate::MAX, &wallet).unwrap();
+ assert_eq!(contribution.value_added, Amount::ZERO);
+ assert!(!contribution.inputs.is_empty(), "coin selection should have added inputs");
+ assert!(contribution.outputs.is_empty());
+ assert_eq!(contribution.feerate, min_rbf_feerate);
+ }
+
+ #[test]
+ fn test_rbf_sync_unadjusted_uses_callers_max_feerate() {
+ // When the prior contribution's feerate is below the minimum RBF feerate and no
+ // holder balance is available, rbf_sync should use the caller's max_feerate (not the
+ // prior's) for the resulting contribution.
+ let min_rbf_feerate = FeeRate::from_sat_per_kwu(5000);
+ let prior_max_feerate = FeeRate::from_sat_per_kwu(50_000);
+ let callers_max_feerate = FeeRate::from_sat_per_kwu(10_000);
+ let withdrawal = funding_output_sats(20_000);
+
+ let prior = FundingContribution {
+ value_added: Amount::ZERO,
+ estimated_fee: Amount::from_sat(500),
+ inputs: vec![],
+ outputs: vec![withdrawal.clone()],
+ change_output: None,
+ feerate: FeeRate::from_sat_per_kwu(2000),
+ max_feerate: prior_max_feerate,
+ is_splice: true,
+ };
+
+ let template = FundingTemplate::new(
+ Some(shared_input(100_000)),
+ Some(min_rbf_feerate),
+ Some(PriorContribution::new(prior, None)),
+ );
+
+ let wallet = SingleUtxoWallet {
+ utxo: funding_input_sats(50_000),
+ change_output: Some(funding_output_sats(40_000)),
+ };
+
+ let contribution = template.rbf_sync(callers_max_feerate, &wallet).unwrap();
+ assert_eq!(
+ contribution.max_feerate, callers_max_feerate,
+ "should use caller's max_feerate, not prior's"
+ );
+ }
+
+ #[test]
+ fn test_splice_out_sync_skips_coin_selection_during_rbf() {
+ // When splice_out_sync is called on a template with min_rbf_feerate set (user
+ // choosing a fresh splice-out instead of rbf_sync), coin selection should NOT run.
+ // Fees come from the channel balance.
+ let min_rbf_feerate = FeeRate::from_sat_per_kwu(5000);
+ let feerate = FeeRate::from_sat_per_kwu(5000);
+ let withdrawal = funding_output_sats(20_000);
+
+ let template =
+ FundingTemplate::new(Some(shared_input(100_000)), Some(min_rbf_feerate), None);
+
+ // UnreachableWallet panics if coin selection runs — verifying it is skipped.
+ let contribution = template
+ .splice_out_sync(vec![withdrawal.clone()], feerate, FeeRate::MAX, UnreachableWallet)
+ .unwrap();
+ assert_eq!(contribution.value_added, Amount::ZERO);
+ assert!(contribution.inputs.is_empty());
+ assert_eq!(contribution.outputs, vec![withdrawal]);
+ }
}
diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs
index 07f2abe..6971e91 100644
--- a/lightning/src/ln/splicing_tests.rs
+++ b/lightning/src/ln/splicing_tests.rs
@@ -237,7 +237,7 @@ pub fn do_initiate_rbf_splice_in<'a, 'b, 'c, 'd>(
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).unwrap();
+ let funding_template = node.node.splice_channel(&channel_id, &node_id_counterparty).unwrap();
let wallet = WalletSync::new(Arc::clone(&node.wallet_source), node.logger);
let funding_contribution =
funding_template.splice_in_sync(value_added, feerate, FeeRate::MAX, &wallet).unwrap();
@@ -252,7 +252,7 @@ pub fn do_initiate_rbf_splice_in_and_out<'a, 'b, 'c, 'd>(
value_added: Amount, outputs: Vec<TxOut>, 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).unwrap();
+ let funding_template = node.node.splice_channel(&channel_id, &node_id_counterparty).unwrap();
let wallet = WalletSync::new(Arc::clone(&node.wallet_source), node.logger);
let funding_contribution = funding_template
.splice_in_and_out_sync(value_added, outputs, feerate, FeeRate::MAX, &wallet)
@@ -4294,7 +4294,7 @@ fn test_splice_acceptor_disconnect_emits_events() {
#[test]
fn test_splice_rbf_acceptor_basic() {
// 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
+ // Complete a splice-in, then use splice_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);
@@ -4321,7 +4321,7 @@ fn test_splice_rbf_acceptor_basic() {
// 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.
+ // Step 3: Use splice_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);
@@ -4384,7 +4384,7 @@ fn test_splice_rbf_insufficient_feerate() {
// Initiator-side: splice_in_sync rejects an insufficient feerate.
// Original feerate was 253. Using exactly 253 should fail since 253 * 24 < 253 * 25.
let same_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64);
- let funding_template = nodes[0].node.rbf_channel(&channel_id, &node_id_1).unwrap();
+ let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap();
// Verify that the template exposes the RBF floor.
let min_rbf_feerate = funding_template.min_rbf_feerate().unwrap();
@@ -4398,7 +4398,7 @@ fn test_splice_rbf_insufficient_feerate() {
.is_err());
// Verify that the floor feerate succeeds.
- let funding_template = nodes[0].node.rbf_channel(&channel_id, &node_id_1).unwrap();
+ let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap();
assert!(funding_template
.splice_in_sync(added_value, min_rbf_feerate, FeeRate::MAX, &wallet)
.is_ok());
@@ -4579,6 +4579,43 @@ fn test_splice_rbf_after_splice_locked() {
}
}
+#[test]
+fn test_splice_zeroconf_no_rbf_feerate() {
+ // Test that splice_channel returns a FundingTemplate with min_rbf_feerate = None for a
+ // zero-conf channel, even when a splice negotiation is in progress.
+ 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);
+
+ let added_value = Amount::from_sat(50_000);
+ provide_utxo_reserves(&nodes, 1, added_value * 2);
+
+ // Initiate a splice (node 0) and complete the handshake so a funding negotiation is 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]);
+
+ // The acceptor (node 1) calling splice_channel should return no RBF feerate since
+ // zero-conf channels cannot RBF.
+ let funding_template = nodes[1].node.splice_channel(&channel_id, &node_id_0).unwrap();
+ assert!(funding_template.min_rbf_feerate().is_none());
+
+ // Drain pending interactive tx messages from the splice handshake.
+ nodes[0].node.get_and_clear_pending_msg_events();
+}
+
#[test]
fn test_splice_rbf_zeroconf_rejected() {
// Test that tx_init_rbf is rejected when option_zeroconf is negotiated.
@@ -4621,10 +4658,7 @@ fn test_splice_rbf_zeroconf_rejected() {
msgs::ErrorAction::DisconnectPeerWithWarning {
msg: msgs::WarningMessage {
channel_id,
- data: format!(
- "Channel {} has option_zeroconf, cannot RBF splice",
- channel_id,
- ),
+ data: format!("Channel {} has option_zeroconf, cannot RBF", channel_id,),
},
}
);
@@ -4740,7 +4774,7 @@ fn test_splice_rbf_tiebreak_feerate_too_high() {
/// 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
+/// Both nodes call `splice_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.
///
@@ -4772,11 +4806,11 @@ pub fn do_test_splice_rbf_tiebreak(
// Provide more UTXOs for both nodes' RBF attempts.
provide_utxo_reserves(&nodes, 2, added_value * 2);
- // Node 0 calls rbf_channel + funding_contributed.
+ // Node 0 calls splice_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.
+ // Node 1 calls splice_channel + funding_contributed.
let node_1_funding_contribution = do_initiate_rbf_splice_in(
&nodes[1],
&nodes[0],
@@ -5034,7 +5068,7 @@ fn test_splice_rbf_tiebreak_feerate_too_high_rejected() {
let min_rbf_feerate = FeeRate::from_sat_per_kwu(min_rbf_feerate_sat_per_kwu);
let node_1_max_feerate = FeeRate::from_sat_per_kwu(3_000);
- let funding_template_0 = nodes[0].node.rbf_channel(&channel_id, &node_id_1).unwrap();
+ let funding_template_0 = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap();
let wallet_0 = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger);
let node_0_funding_contribution = funding_template_0
.splice_in_sync(added_value, high_feerate, FeeRate::MAX, &wallet_0)
@@ -5044,7 +5078,7 @@ fn test_splice_rbf_tiebreak_feerate_too_high_rejected() {
.funding_contributed(&channel_id, &node_id_1, node_0_funding_contribution.clone(), None)
.unwrap();
- let funding_template_1 = nodes[1].node.rbf_channel(&channel_id, &node_id_0).unwrap();
+ let funding_template_1 = nodes[1].node.splice_channel(&channel_id, &node_id_0).unwrap();
let wallet_1 = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger);
let node_1_funding_contribution = funding_template_1
.splice_in_sync(added_value, min_rbf_feerate, node_1_max_feerate, &wallet_1)
@@ -5159,7 +5193,7 @@ fn test_splice_rbf_acceptor_recontributes() {
// Step 4: Provide new UTXOs for node 0's RBF (node 1 does NOT initiate RBF).
provide_utxo_reserves(&nodes, 2, added_value * 2);
- // Step 5: Only node 0 calls rbf_channel + funding_contributed.
+ // Step 5: Only node 0 calls splice_channel + funding_contributed.
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 rbf_funding_contribution =
@@ -5203,6 +5237,127 @@ fn test_splice_rbf_acceptor_recontributes() {
);
}
+#[test]
+fn test_splice_rbf_after_counterparty_rbf_aborted() {
+ // When a counterparty-initiated RBF is aborted, the acceptor's prior contribution retains
+ // the adjusted feerate. Initiating our own RBF afterward must not panic even though the
+ // prior contribution's feerate may be >= the new rbf_feerate.
+ 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, Amount::from_sat(100_000));
+
+ // Step 1: Both nodes initiate a splice at floor feerate.
+ let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64);
+
+ let funding_template_0 = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap();
+ let wallet_0 = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger);
+ let node_0_funding_contribution =
+ funding_template_0.splice_in_sync(added_value, feerate, FeeRate::MAX, &wallet_0).unwrap();
+ nodes[0]
+ .node
+ .funding_contributed(&channel_id, &node_id_1, node_0_funding_contribution.clone(), None)
+ .unwrap();
+
+ let funding_template_1 = nodes[1].node.splice_channel(&channel_id, &node_id_0).unwrap();
+ let wallet_1 = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger);
+ let node_1_funding_contribution =
+ funding_template_1.splice_in_sync(added_value, feerate, FeeRate::MAX, &wallet_1).unwrap();
+ nodes[1]
+ .node
+ .funding_contributed(&channel_id, &node_id_0, node_1_funding_contribution.clone(), None)
+ .unwrap();
+
+ // Step 2: Tiebreak — node 0 wins, both contribute to initial splice.
+ let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1);
+ let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0);
+
+ nodes[1].node.handle_stfu(node_id_0, &stfu_0);
+ assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty());
+ nodes[0].node.handle_stfu(node_id_1, &stfu_1);
+
+ let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1);
+ nodes[1].node.handle_splice_init(node_id_0, &splice_init);
+ let splice_ack = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceAck, node_id_0);
+ nodes[0].node.handle_splice_ack(node_id_1, &splice_ack);
+
+ let new_funding_script = chan_utils::make_funding_redeemscript(
+ &splice_init.funding_pubkey,
+ &splice_ack.funding_pubkey,
+ )
+ .to_p2wsh();
+
+ complete_interactive_funding_negotiation_for_both(
+ &nodes[0],
+ &nodes[1],
+ channel_id,
+ node_0_funding_contribution,
+ Some(node_1_funding_contribution),
+ splice_ack.funding_contribution_satoshis,
+ new_funding_script,
+ );
+
+ let (_first_splice_tx, splice_locked) =
+ sign_interactive_funding_tx_with_acceptor_contribution(&nodes[0], &nodes[1], false, true);
+ assert!(splice_locked.is_none());
+
+ expect_splice_pending_event(&nodes[0], &node_id_1);
+ expect_splice_pending_event(&nodes[1], &node_id_0);
+
+ // Step 3: Node 0 initiates RBF. Node 1 has no QuiescentAction, so its prior contribution
+ // is adjusted to the RBF feerate via for_acceptor_at_feerate.
+ provide_utxo_reserves(&nodes, 2, added_value * 2);
+
+ let rbf_feerate =
+ FeeRate::from_sat_per_kwu((FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24));
+ let _rbf_funding_contribution =
+ do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate);
+
+ let tx_ack_rbf = complete_rbf_handshake(&nodes[0], &nodes[1]);
+ assert!(tx_ack_rbf.funding_output_contribution.is_some());
+
+ // Step 4: Abort the RBF. Node 0 sends tx_abort; node 1's prior contribution retains the
+ // adjusted feerate.
+ // Drain node 0's pending TxAddInput from the interactive tx negotiation start.
+ nodes[0].node.get_and_clear_pending_msg_events();
+
+ let tx_abort = msgs::TxAbort { channel_id, data: vec![] };
+ nodes[1].node.handle_tx_abort(node_id_0, &tx_abort);
+
+ let msg_events = nodes[1].node.get_and_clear_pending_msg_events();
+ assert!(!msg_events.is_empty());
+ let tx_abort_echo = match &msg_events[0] {
+ MessageSendEvent::SendTxAbort { msg, .. } => msg.clone(),
+ other => panic!("Expected SendTxAbort, got {:?}", other),
+ };
+
+ nodes[0].node.handle_tx_abort(node_id_1, &tx_abort_echo);
+ nodes[0].node.get_and_clear_pending_msg_events();
+ nodes[0].node.get_and_clear_pending_events();
+ nodes[1].node.get_and_clear_pending_events();
+
+ // Step 5: Node 1 initiates its own RBF via splice_channel → rbf_sync.
+ // The prior contribution's feerate is now >= rbf_feerate. This must not panic.
+ provide_utxo_reserves(&nodes, 2, added_value * 2);
+
+ let funding_template = nodes[1].node.splice_channel(&channel_id, &node_id_0).unwrap();
+ assert!(funding_template.min_rbf_feerate().is_some());
+
+ let wallet = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger);
+ let rbf_contribution = funding_template.rbf_sync(FeeRate::MAX, &wallet);
+ assert!(rbf_contribution.is_ok());
+}
+
#[test]
fn test_splice_rbf_recontributes_feerate_too_high() {
// When the counterparty RBFs at a feerate too high for our prior contribution,
@@ -5288,7 +5443,7 @@ fn test_splice_rbf_recontributes_feerate_too_high() {
provide_utxo_reserves(&nodes, 2, Amount::from_sat(100_000));
let high_feerate = FeeRate::from_sat_per_kwu(20_000);
- let funding_template = nodes[0].node.rbf_channel(&channel_id, &node_id_1).unwrap();
+ let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap();
let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger);
let rbf_funding_contribution = funding_template
.splice_in_sync(Amount::from_sat(50_000), high_feerate, FeeRate::MAX, &wallet)
@@ -5630,8 +5785,8 @@ fn test_splice_rbf_disconnect_filters_prior_contributions() {
#[test]
fn test_splice_channel_with_pending_splice_includes_rbf_floor() {
- // Test that splice_channel (not rbf_channel) includes the RBF floor when a pending splice
- // exists with negotiated candidates.
+ // Test that splice_channel includes the RBF floor when a pending splice exists with
+ // negotiated candidates.
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]);
@@ -5646,33 +5801,39 @@ fn test_splice_channel_with_pending_splice_includes_rbf_floor() {
let added_value = Amount::from_sat(50_000);
provide_utxo_reserves(&nodes, 2, added_value * 2);
+ // Fresh splice — no pending splice, so no prior contribution or minimum RBF feerate.
+ {
+ let template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap();
+ assert!(template.min_rbf_feerate().is_none());
+ assert!(template.prior_contribution().is_none());
+ }
+
// Complete a splice-in at floor feerate.
let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value);
let (_splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution);
- // Call splice_channel (not rbf_channel) — the pending splice should cause
- // min_rbf_feerate to be set.
+ // Call splice_channel again — the pending splice should cause min_rbf_feerate to be set
+ // and the prior contribution to be available.
let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap();
let expected_floor =
FeeRate::from_sat_per_kwu(((FEERATE_FLOOR_SATS_PER_KW as u64) * 25).div_ceil(24));
assert_eq!(funding_template.min_rbf_feerate(), Some(expected_floor));
+ assert!(funding_template.prior_contribution().is_some());
- // Successfully build a contribution at the floor feerate.
+ // rbf_sync returns the Adjusted prior contribution directly.
let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger);
- assert!(funding_template
- .splice_in_sync(added_value, expected_floor, FeeRate::MAX, &wallet)
- .is_ok());
+ assert!(funding_template.rbf_sync(FeeRate::MAX, &wallet).is_ok());
}
#[test]
fn test_funding_contributed_adjusts_feerate_for_rbf() {
- // Test that funding_contributed adjusts the contribution's feerate to the minimum RBF feerate when a
- // pending splice appears between splice_channel and funding_contributed.
+ // Test that funding_contributed adjusts the contribution's feerate to the minimum RBF feerate
+ // when a pending splice appears between splice_channel and funding_contributed.
//
// Node 0 calls splice_channel (no pending splice → min_rbf_feerate = None) and builds a
// contribution at floor feerate. Node 1 then initiates and completes a splice. When node 0
- // calls funding_contributed, the contribution is adjusted to the minimum RBF feerate and STFU is sent
- // immediately.
+ // calls funding_contributed, the contribution is adjusted to the minimum RBF feerate and STFU
+ // is sent immediately.
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]);
@@ -5723,9 +5884,9 @@ fn test_funding_contributed_adjusts_feerate_for_rbf() {
#[test]
fn test_funding_contributed_rbf_adjustment_exceeds_max_feerate() {
- // Test that when the minimum RBF feerate exceeds max_feerate, the adjustment in funding_contributed
- // fails gracefully and the contribution keeps its original feerate. The splice still
- // proceeds (STFU is sent) and the RBF negotiation handles the feerate mismatch.
+ // Test that when the minimum RBF feerate exceeds max_feerate, the adjustment in
+ // funding_contributed fails gracefully and the contribution keeps its original feerate. The
+ // splice still proceeds (STFU is sent) and the RBF negotiation handles the feerate mismatch.
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]);
@@ -5754,8 +5915,8 @@ fn test_funding_contributed_rbf_adjustment_exceeds_max_feerate() {
let node_1_contribution = do_initiate_splice_in(&nodes[1], &nodes[0], channel_id, added_value);
let (_splice_tx, _) = splice_channel(&nodes[1], &nodes[0], channel_id, node_1_contribution);
- // Node 0 calls funding_contributed. The adjustment fails (minimum RBF feerate > max_feerate), but
- // funding_contributed still succeeds — the contribution keeps its original feerate.
+ // Node 0 calls funding_contributed. The adjustment fails (minimum RBF feerate > max_feerate),
+ // but funding_contributed still succeeds — the contribution keeps its original feerate.
nodes[0].node.funding_contributed(&channel_id, &node_id_1, contribution, None).unwrap();
// STFU is NOT sent — the feerate is below the minimum RBF feerate so try_send_stfu delays.
@@ -5859,3 +6020,151 @@ fn test_funding_contributed_rbf_adjustment_insufficient_budget() {
let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1);
assert_eq!(splice_init.funding_feerate_per_kw, FEERATE_FLOOR_SATS_PER_KW);
}
+
+#[test]
+fn test_prior_contribution_unadjusted_when_max_feerate_too_low() {
+ // Test that rbf_sync re-runs coin selection when the prior contribution's max_feerate is
+ // too low to accommodate the minimum RBF feerate.
+ 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_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 with max_feerate = floor_feerate. This means the prior contribution
+ // stored in pending_splice.contributions will have a tight max_feerate.
+ let floor_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64);
+ let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap();
+ let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger);
+ let funding_contribution = funding_template
+ .splice_in_sync(added_value, floor_feerate, floor_feerate, &wallet)
+ .unwrap();
+ nodes[0]
+ .node
+ .funding_contributed(&channel_id, &node_id_1, funding_contribution.clone(), None)
+ .unwrap();
+ let (_splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution);
+
+ // Call splice_channel again — the minimum RBF feerate (25/24 of floor) exceeds the prior
+ // contribution's max_feerate (floor), so adjustment fails. rbf_sync re-runs coin selection
+ // with the caller's max_feerate.
+ let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap();
+ assert!(funding_template.min_rbf_feerate().is_some());
+ assert!(funding_template.prior_contribution().is_some());
+ let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger);
+ assert!(funding_template.rbf_sync(FeeRate::MAX, &wallet).is_ok());
+}
+
+#[test]
+fn test_splice_channel_during_negotiation_includes_rbf_feerate() {
+ // Test that splice_channel returns min_rbf_feerate derived from the in-progress
+ // negotiation's feerate when the acceptor calls it during active negotiation.
+ 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);
+
+ // Node 1 initiates a splice. Perform stfu exchange and splice_init handling, which creates
+ // a pending_splice with funding_negotiation on node 0 (the acceptor).
+ let _funding_contribution =
+ do_initiate_splice_in(&nodes[1], &nodes[0], channel_id, added_value);
+ let stfu_init = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0);
+ nodes[0].node.handle_stfu(node_id_1, &stfu_init);
+ let stfu_ack = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1);
+ nodes[1].node.handle_stfu(node_id_0, &stfu_ack);
+
+ let splice_init = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceInit, node_id_0);
+ 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 0 (acceptor) calls splice_channel while the negotiation is in progress.
+ // min_rbf_feerate should be derived from the in-progress negotiation's feerate.
+ let template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap();
+ let expected_floor =
+ FeeRate::from_sat_per_kwu(((FEERATE_FLOOR_SATS_PER_KW as u64) * 25).div_ceil(24));
+ assert_eq!(template.min_rbf_feerate(), Some(expected_floor));
+
+ // No prior contribution since there are no negotiated candidates yet. rbf_sync runs
+ // fee-bump-only coin selection.
+ assert!(template.prior_contribution().is_none());
+ let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger);
+ assert!(template.rbf_sync(FeeRate::MAX, &wallet).is_ok());
+}
+
+#[test]
+fn test_rbf_sync_returns_err_when_no_min_rbf_feerate() {
+ // Test that rbf_sync returns Err(()) when there is no pending splice (min_rbf_feerate is
+ // None), indicating this is not an RBF scenario.
+ 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_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);
+
+ // Fresh splice — no pending splice, so min_rbf_feerate is None.
+ let template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap();
+ assert!(template.min_rbf_feerate().is_none());
+ assert!(template.prior_contribution().is_none());
+
+ let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger);
+ assert!(template.rbf_sync(FeeRate::MAX, &wallet).is_err());
+}
+
+#[test]
+fn test_rbf_sync_returns_err_when_max_feerate_below_min_rbf() {
+ // Test that rbf_sync returns Err(()) when the caller's max_feerate is below the minimum
+ // RBF feerate.
+ 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_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 to create a pending splice.
+ let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value);
+ let (_splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution);
+
+ // Call splice_channel again to get the RBF template.
+ let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap();
+ let min_rbf_feerate = funding_template.min_rbf_feerate().unwrap();
+
+ // Use a max_feerate that is 1 sat/kwu below the minimum RBF feerate.
+ let too_low_feerate =
+ FeeRate::from_sat_per_kwu(min_rbf_feerate.to_sat_per_kwu().saturating_sub(1));
+ let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger);
+ assert!(funding_template.rbf_sync(too_low_feerate, &wallet).is_err());
+}
diff --git a/lightning/src/util/wallet_utils.rs b/lightning/src/util/wallet_utils.rs
index b82437c..6122840 100644
--- a/lightning/src/util/wallet_utils.rs
+++ b/lightning/src/util/wallet_utils.rs
@@ -148,7 +148,7 @@ impl Utxo {
///
/// Can be used as an input to contribute to a channel's funding transaction either when using the
/// v2 channel establishment protocol or when splicing.
-#[derive(Debug, Clone)]
+#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfirmedUtxo {
/// The unspent [`TxOut`] found in [`prevtx`].
///
Why this scored 24/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.