Skip to content

Commit 8fd4927

Browse files
jkczyzclaude
andcommitted
Merge rbf_channel into splice_channel and expose prior contribution
Users previously had to choose between splice_channel (fresh splice) and rbf_channel (fee bump) upfront. Since splice_channel already detects pending splices and computes the minimum RBF feerate, rbf_channel was redundant. Merging into a single API lets the user call one method and discover from the returned FundingTemplate whether an RBF is possible. The FundingTemplate now carries the user's prior contribution from the previous splice negotiation when one is available. This lets users reuse their existing contribution for an RBF without performing new coin selection. A PriorContribution enum distinguishes whether the contribution has been adjusted to the minimum RBF feerate (Adjusted) or could not be adjusted due to insufficient fee buffer or max_feerate constraints (Unadjusted). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c132cc9 commit 8fd4927

5 files changed

Lines changed: 749 additions & 214 deletions

File tree

lightning/src/ln/channel.rs

Lines changed: 65 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ use crate::ln::channelmanager::{
5656
MAX_LOCAL_BREAKDOWN_TIMEOUT, MIN_CLTV_EXPIRY_DELTA,
5757
};
5858
use crate::ln::funding::{
59-
FeeRateAdjustmentError, FundingContribution, FundingTemplate, FundingTxInput,
59+
FeeRateAdjustmentError, FundingContribution, FundingTemplate, FundingTxInput, PriorContribution,
6060
};
6161
use crate::ln::interactivetxs::{
6262
AbortReason, HandleTxCompleteValue, InteractiveTxConstructor, InteractiveTxConstructorArgs,
@@ -11907,7 +11907,7 @@ where
1190711907
}
1190811908
}
1190911909

11910-
/// Initiate splicing.
11910+
/// Builds a [`FundingTemplate`] for splicing or RBF, if the channel state allows it.
1191111911
pub fn splice_channel(&self) -> Result<FundingTemplate, APIError> {
1191211912
if self.holder_commitment_point.current_point().is_none() {
1191311913
return Err(APIError::APIMisuseError {
@@ -11950,19 +11950,35 @@ where
1195011950
});
1195111951
}
1195211952

11953-
// Compute the RBF feerate floor from either negotiated candidates (via
11954-
// can_initiate_rbf) or an in-progress funding negotiation (which will become a
11955-
// negotiated candidate once it completes).
11956-
let min_rbf_feerate = self.can_initiate_rbf().ok().flatten().or_else(|| {
11957-
self.pending_splice
11958-
.as_ref()
11959-
.and_then(|pending_splice| pending_splice.funding_negotiation.as_ref())
11960-
.map(|negotiation| {
11961-
let prev_feerate = negotiation.funding_feerate_sat_per_1000_weight();
11962-
let min_feerate_kwu = ((prev_feerate as u64) * 25).div_ceil(24);
11963-
FeeRate::from_sat_per_kwu(min_feerate_kwu)
11964-
})
11965-
});
11953+
let (min_rbf_feerate, prior_contribution) = if self.is_rbf_compatible().is_err() {
11954+
// Channel can never RBF (e.g., zero-conf).
11955+
(None, None)
11956+
} else if let Ok(min_rbf_feerate) = self.can_initiate_rbf() {
11957+
// A previous splice was negotiated but not yet locked. The user's splice
11958+
// will be an RBF, so provide the minimum RBF feerate and prior contribution.
11959+
let prior = self.build_prior_contribution();
11960+
(Some(min_rbf_feerate), prior)
11961+
} else if let Some(negotiation) = self
11962+
.pending_splice
11963+
.as_ref()
11964+
.and_then(|pending_splice| pending_splice.funding_negotiation.as_ref())
11965+
{
11966+
// A splice is currently being negotiated.
11967+
// - If the negotiation succeeds, the user's splice will need to satisfy the RBF
11968+
// feerate requirement. Derive the minimum RBF feerate from the negotiation's
11969+
// feerate so the user can choose an appropriate feerate.
11970+
// - If the negotiation fails (e.g., tx_abort), the splice will proceed as a fresh
11971+
// splice instead. In this case, the min_rbf_feerate becomes stale, causing a
11972+
// slightly higher feerate than necessary. Call splice_channel again after
11973+
// receiving SpliceFailed to get a fresh template without the RBF constraint.
11974+
let prev_feerate = negotiation.funding_feerate_sat_per_1000_weight();
11975+
let min_feerate_kwu = ((prev_feerate as u64) * 25).div_ceil(24);
11976+
(Some(FeeRate::from_sat_per_kwu(min_feerate_kwu)), None)
11977+
} else {
11978+
// No RBF feerate to derive — either a fresh splice or a pending splice that
11979+
// can't be RBF'd (e.g., splice_locked already exchanged).
11980+
(None, None)
11981+
};
1196611982

1196711983
let funding_txo = self.funding.get_funding_txo().expect("funding_txo should be set");
1196811984
let previous_utxo =
@@ -11973,63 +11989,35 @@ where
1197311989
satisfaction_weight: EMPTY_SCRIPT_SIG_WEIGHT + FUNDING_TRANSACTION_WITNESS_WEIGHT,
1197411990
};
1197511991

11976-
Ok(FundingTemplate::new(Some(shared_input), min_rbf_feerate))
11992+
Ok(FundingTemplate::new(Some(shared_input), min_rbf_feerate, prior_contribution))
1197711993
}
1197811994

11979-
/// Initiate an RBF of a pending splice transaction.
11980-
pub fn rbf_channel(&self) -> Result<FundingTemplate, APIError> {
11981-
if self.holder_commitment_point.current_point().is_none() {
11982-
return Err(APIError::APIMisuseError {
11983-
err: format!(
11984-
"Channel {} cannot RBF until a payment is routed",
11985-
self.context.channel_id(),
11986-
),
11987-
});
11988-
}
11989-
11990-
if self.quiescent_action.is_some() {
11991-
return Err(APIError::APIMisuseError {
11992-
err: format!(
11993-
"Channel {} cannot RBF as one is waiting to be negotiated",
11994-
self.context.channel_id(),
11995-
),
11996-
});
11997-
}
11998-
11999-
if !self.context.is_usable() {
12000-
return Err(APIError::APIMisuseError {
12001-
err: format!(
12002-
"Channel {} cannot RBF as it is either pending open/close",
12003-
self.context.channel_id()
12004-
),
12005-
});
12006-
}
11995+
/// Clones the prior contribution and fetches the holder balance for deferred feerate
11996+
/// adjustment.
11997+
fn build_prior_contribution(&self) -> Option<PriorContribution> {
11998+
debug_assert!(self.pending_splice.is_some(), "can_initiate_rbf requires pending_splice");
11999+
let prior = self.pending_splice.as_ref()?.contributions.last()?;
12000+
let holder_balance = self
12001+
.get_holder_counterparty_balances_floor_incl_fee(&self.funding)
12002+
.map(|(h, _)| h)
12003+
.ok();
12004+
Some(PriorContribution::new(prior.clone(), holder_balance))
12005+
}
1200712006

12007+
/// Returns whether this channel can ever RBF, independent of splice state.
12008+
fn is_rbf_compatible(&self) -> Result<(), String> {
1200812009
if self.context.minimum_depth(&self.funding) == Some(0) {
12009-
return Err(APIError::APIMisuseError {
12010-
err: format!(
12011-
"Channel {} has option_zeroconf, cannot RBF splice",
12012-
self.context.channel_id(),
12013-
),
12014-
});
12010+
return Err(format!(
12011+
"Channel {} has option_zeroconf, cannot RBF",
12012+
self.context.channel_id(),
12013+
));
1201512014
}
12016-
12017-
let min_rbf_feerate =
12018-
self.can_initiate_rbf().map_err(|err| APIError::APIMisuseError { err })?;
12019-
12020-
let funding_txo = self.funding.get_funding_txo().expect("funding_txo should be set");
12021-
let previous_utxo =
12022-
self.funding.get_funding_output().expect("funding_output should be set");
12023-
let shared_input = Input {
12024-
outpoint: funding_txo.into_bitcoin_outpoint(),
12025-
previous_utxo,
12026-
satisfaction_weight: EMPTY_SCRIPT_SIG_WEIGHT + FUNDING_TRANSACTION_WITNESS_WEIGHT,
12027-
};
12028-
12029-
Ok(FundingTemplate::new(Some(shared_input), min_rbf_feerate))
12015+
Ok(())
1203012016
}
1203112017

12032-
fn can_initiate_rbf(&self) -> Result<Option<FeeRate>, String> {
12018+
fn can_initiate_rbf(&self) -> Result<FeeRate, String> {
12019+
self.is_rbf_compatible()?;
12020+
1203312021
let pending_splice = match &self.pending_splice {
1203412022
Some(pending_splice) => pending_splice,
1203512023
None => {
@@ -12068,13 +12056,16 @@ where
1206812056
));
1206912057
}
1207012058

12071-
let min_rbf_feerate =
12072-
pending_splice.last_funding_feerate_sat_per_1000_weight.map(|prev_feerate| {
12059+
match pending_splice.last_funding_feerate_sat_per_1000_weight {
12060+
Some(prev_feerate) => {
1207312061
let min_feerate_kwu = ((prev_feerate as u64) * 25).div_ceil(24);
12074-
FeeRate::from_sat_per_kwu(min_feerate_kwu)
12075-
});
12076-
12077-
Ok(min_rbf_feerate)
12062+
Ok(FeeRate::from_sat_per_kwu(min_feerate_kwu))
12063+
},
12064+
None => Err(format!(
12065+
"Channel {} has no prior feerate to compute RBF minimum",
12066+
self.context.channel_id(),
12067+
)),
12068+
}
1207812069
}
1207912070

1208012071
/// Attempts to adjust the contribution's feerate to the minimum RBF feerate so the splice can
@@ -12205,7 +12196,7 @@ where
1220512196
// If a pending splice exists with negotiated candidates, attempt to adjust the
1220612197
// contribution's feerate to the minimum RBF feerate so it can proceed as an RBF immediately
1220712198
// rather than waiting for the splice to lock.
12208-
let contribution = if let Ok(Some(min_rbf_feerate)) = self.can_initiate_rbf() {
12199+
let contribution = if let Ok(min_rbf_feerate) = self.can_initiate_rbf() {
1220912200
self.maybe_adjust_for_rbf(contribution, min_rbf_feerate, logger)
1221012201
} else {
1221112202
contribution
@@ -12605,12 +12596,7 @@ where
1260512596
return Err(ChannelError::WarnAndDisconnect("Quiescence needed for RBF".to_owned()));
1260612597
}
1260712598

12608-
if self.context.minimum_depth(&self.funding) == Some(0) {
12609-
return Err(ChannelError::WarnAndDisconnect(format!(
12610-
"Channel {} has option_zeroconf, cannot RBF splice",
12611-
self.context.channel_id(),
12612-
)));
12613-
}
12599+
self.is_rbf_compatible().map_err(|msg| ChannelError::WarnAndDisconnect(msg))?;
1261412600

1261512601
let pending_splice = match &self.pending_splice {
1261612602
Some(pending_splice) => pending_splice,
@@ -13817,7 +13803,7 @@ where
1381713803
);
1381813804
return None;
1381913805
},
13820-
Ok(Some(min_rbf_feerate)) if contribution.feerate() < min_rbf_feerate => {
13806+
Ok(min_rbf_feerate) if contribution.feerate() < min_rbf_feerate => {
1382113807
log_given_level!(
1382213808
logger,
1382313809
logger_level,

lightning/src/ln/channelmanager.rs

Lines changed: 12 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -4701,24 +4701,21 @@ impl<
47014701
}
47024702

47034703
/// Initiate a splice in order to add value to (splice-in) or remove value from (splice-out)
4704-
/// the channel. This will spend the channel's funding transaction output, effectively replacing
4705-
/// it with a new one.
4704+
/// the channel, or to RBF a pending splice transaction.
47064705
///
47074706
/// # Required Feature Flags
47084707
///
47094708
/// Initiating a splice requires that the channel counterparty supports splicing. Any
47104709
/// channel (no matter the type) can be spliced, as long as the counterparty is currently
47114710
/// connected.
47124711
///
4713-
/// Returns a [`FundingTemplate`] which should be used to build a [`FundingContribution`] via
4714-
/// one of its splice methods (e.g., [`FundingTemplate::splice_in_sync`]). The `min_feerate`
4715-
/// and `max_feerate` parameters are provided when calling those splice methods. The resulting
4716-
/// contribution must then be passed to [`ChannelManager::funding_contributed`].
4712+
/// # Return Value
47174713
///
4718-
/// When a pending splice exists with negotiated candidates (i.e., a splice that hasn't been
4719-
/// locked yet), [`FundingTemplate::min_rbf_feerate`] will return the minimum feerate required
4720-
/// for an RBF attempt (25/24 of the previous feerate). This can be used to choose an
4721-
/// appropriate `min_feerate` when calling the splice methods.
4714+
/// Returns a [`FundingTemplate`] which should be used to obtain a [`FundingContribution`]
4715+
/// to pass to [`ChannelManager::funding_contributed`]. If a splice has been negotiated but
4716+
/// not yet locked, it can be replaced with a higher feerate transaction to speed up
4717+
/// confirmation via Replace By Fee (RBF). See [`FundingTemplate`] for details on building
4718+
/// a fresh contribution or reusing a prior one for RBF.
47224719
#[rustfmt::skip]
47234720
pub fn splice_channel(
47244721
&self, channel_id: &ChannelId, counterparty_node_id: &PublicKey,
@@ -4765,67 +4762,6 @@ impl<
47654762
}
47664763
}
47674764

4768-
/// Initiate an RBF of a pending splice transaction for an existing channel.
4769-
///
4770-
/// This is used after a splice has been negotiated but before it has been locked, in order
4771-
/// to bump the feerate of the funding transaction via replace-by-fee.
4772-
///
4773-
/// # Required Feature Flags
4774-
///
4775-
/// Initiating an RBF requires that the channel counterparty supports splicing. The
4776-
/// counterparty must be currently connected.
4777-
///
4778-
/// Returns a [`FundingTemplate`] which should be used to build a [`FundingContribution`] via
4779-
/// one of its splice methods (e.g., [`FundingTemplate::splice_in_sync`]). The `min_feerate`
4780-
/// and `max_feerate` parameters are provided when calling those splice methods.
4781-
/// [`FundingTemplate::min_rbf_feerate`] returns the minimum feerate required for the RBF
4782-
/// (25/24 of the previous feerate). The resulting contribution must then be passed to
4783-
/// [`ChannelManager::funding_contributed`].
4784-
pub fn rbf_channel(
4785-
&self, channel_id: &ChannelId, counterparty_node_id: &PublicKey,
4786-
) -> Result<FundingTemplate, APIError> {
4787-
let per_peer_state = self.per_peer_state.read().unwrap();
4788-
4789-
let peer_state_mutex = match per_peer_state
4790-
.get(counterparty_node_id)
4791-
.ok_or_else(|| APIError::no_such_peer(counterparty_node_id))
4792-
{
4793-
Ok(p) => p,
4794-
Err(e) => return Err(e),
4795-
};
4796-
4797-
let mut peer_state = peer_state_mutex.lock().unwrap();
4798-
if !peer_state.latest_features.supports_splicing() {
4799-
return Err(APIError::ChannelUnavailable {
4800-
err: "Peer does not support splicing".to_owned(),
4801-
});
4802-
}
4803-
if !peer_state.latest_features.supports_quiescence() {
4804-
return Err(APIError::ChannelUnavailable {
4805-
err: "Peer does not support quiescence, a splicing prerequisite".to_owned(),
4806-
});
4807-
}
4808-
4809-
// Look for the channel
4810-
match peer_state.channel_by_id.entry(*channel_id) {
4811-
hash_map::Entry::Occupied(chan_phase_entry) => {
4812-
if let Some(chan) = chan_phase_entry.get().as_funded() {
4813-
chan.rbf_channel()
4814-
} else {
4815-
Err(APIError::ChannelUnavailable {
4816-
err: format!(
4817-
"Channel with id {} is not funded, cannot RBF splice",
4818-
channel_id
4819-
),
4820-
})
4821-
}
4822-
},
4823-
hash_map::Entry::Vacant(_) => {
4824-
Err(APIError::no_such_channel_for_peer(channel_id, counterparty_node_id))
4825-
},
4826-
}
4827-
}
4828-
48294765
#[cfg(test)]
48304766
pub(crate) fn abandon_splice(
48314767
&self, channel_id: &ChannelId, counterparty_node_id: &PublicKey,
@@ -6590,13 +6526,16 @@ impl<
65906526
///
65916527
/// If any failures occur while negotiating the funding transaction, an [`Event::SpliceFailed`]
65926528
/// will be emitted. Any contributed inputs no longer used will be included in an
6593-
/// [`Event::DiscardFunding`] and thus can be re-spent.
6529+
/// [`Event::DiscardFunding`] and thus can be re-spent. If a [`FundingTemplate`] was obtained
6530+
/// while a previous splice was still being negotiated, its
6531+
/// [`min_rbf_feerate`][FundingTemplate::min_rbf_feerate] may be stale after the failure.
6532+
/// Call this method again to get a fresh template.
65946533
///
65956534
/// After initial signatures have been exchanged, [`Event::FundingTransactionReadyForSigning`]
65966535
/// will be generated and [`ChannelManager::funding_transaction_signed`] should be called.
65976536
///
65986537
/// Once the splice has been locked by both counterparties, an [`Event::ChannelReady`] will be
6599-
/// emitted with the new funding output. At this point, a new splice can be negotiated by
6538+
/// emitted with the new funding output. At this point, a new (non-RBF) splice can be negotiated by
66006539
/// calling [`ChannelManager::splice_channel`] again on this channel.
66016540
///
66026541
/// # Errors

0 commit comments

Comments
 (0)