Replace FundingTemplate contribution methods with FundingBuilder
What changed, and why it matters
This commit refactors how users build funding contributions for Lightning channel splicing and RBF (fee-bumping). It replaces several convenience methods with a builder-style API and changes the behavior so that, when a prior splice request exists, new calls amend (add to) the prior request instead of silently replacing it. The commit also removes the ability to start an RBF attempt when there is no prior contribution, and adds tests showing that prior contributions can be amended. There is no explicit security bug in the diff, but the behavioral change could affect how wallets construct splice transactions and could, if callers misunderstand the new semantics, lead to unintended transaction shapes or fee handling.
Treat this as a behavior-affecting API change rather than a confirmed vulnerability. Downstream wallet integrators using rust-lightning's splicing/RBF APIs should review their usage: calls that previously replaced a prior contribution may now amend it, and `rbf_sync` no longer supports fee-bump-only RBF without a prior contribution. If a security issue is suspected, request a security advisory or CVE from the project maintainers and verify whether the behavioral change was made to fix a known bug.
Security signals we found
Behavioral change in public API: prior contribution is now amended rather than replaced by convenience methods
RBF entry points now require a prior contribution and reject fee-bump-only RBF without one
New builder methods allow callers to add/remove value and outputs from a stored prior contribution
Validation logic for MAX_MONEY and feerate bounds is preserved but reorganized into the builder
No explicit security advisory, CVE, or vulnerability disclosure is present in the commit or supplied references
Evidence from the diff
The patch rewrites FundingTemplate contribution construction in lightning/src/ln/funding.rs. It removes the monolithic build_funding_contribution! macro and the splice_in_and_out* methods, and exposes FundingBuilder methods (without_prior_contribution, with_prior_contribution, add_value, remove_value, add_outputs, remove_outputs, build). Key behavioral changes: (1) splice_in/splice_out now seed the builder from with_prior_contribution, so value_added and outputs are appended to the prior contribution rather than replacing it; (2) rbf/rbf_sync are renamed to rbf_prior_contribution* and now require a prior contribution, returning NotRbfScenario when none exists; (3) an explicit feerate override can be passed to RBF. Tests are updated/added to verify amendment semantics for both net-positive and net-negative prior contributions. The diff does not show a concrete vulnerability such as unchecked arithmetic, unauthorized spending, or protocol state bypass; it is primarily an API/behavior refactor with safety-relevant semantics.
Changed components
lightning/src/ln/funding.rslightning/src/ln/splicing_tests.rsFundingTemplateFundingBuilder / FundingBuilderInner / AsyncFundingBuilder / SyncFundingBuilderFundingContributionChannelManager::splice_channel callersInspect captured patch +581 / −492
diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs
index b628f54..cb519b3 100644
--- a/lightning/src/ln/funding.rs
+++ b/lightning/src/ln/funding.rs
@@ -27,14 +27,15 @@ use crate::util::wallet_utils::{
CoinSelection, CoinSelectionSource, CoinSelectionSourceSync, Input,
};
-/// Error returned when the acceptor's contribution cannot accommodate the initiator's proposed
-/// feerate.
+/// Error returned when a [`FundingContribution`] cannot be adjusted to a target feerate.
///
-/// When building a [`FundingContribution`], fees are estimated at `min_feerate` assuming initiator
-/// responsibility. If the counterparty also initiates a splice and wins the tie-break, they become
-/// the initiator and choose the feerate. The fee is then re-estimated at the counterparty's
-/// feerate for only our contributed inputs and outputs. When this re-estimation fails, the
-/// contribution is dropped and the counterparty's splice proceeds without it.
+/// This is used when re-estimating an already-built contribution at a different feerate than the
+/// one used during coin selection. That includes, for example, acceptor-side adjustment to the
+/// initiator's chosen feerate during splice tie-break resolution, as well as initiator-side
+/// adjustment to a minimum RBF feerate for later attempts.
+///
+/// Callers decide how to handle the failure. Depending on the context, they may drop the
+/// contribution, wait and retry later, or abort the splice negotiation.
///
/// See [`ChannelManager::splice_channel`] for further details.
///
@@ -146,7 +147,7 @@ pub enum FundingContributionError {
/// the builder fall back to fresh coin selection, which may replace the prior input set instead
/// of preserving it.
MissingCoinSelectionSource,
- /// This is not an RBF scenario (no minimum RBF feerate available).
+ /// This template cannot build an RBF contribution.
NotRbfScenario,
}
@@ -172,7 +173,7 @@ impl core::fmt::Display for FundingContributionError {
write!(f, "Coin selection source required to build this contribution")
},
FundingContributionError::NotRbfScenario => {
- write!(f, "Not an RBF scenario (no minimum RBF feerate)")
+ write!(f, "This template cannot build an RBF contribution")
},
}
}
@@ -181,13 +182,13 @@ impl core::fmt::Display for FundingContributionError {
/// 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`].
+/// available for reuse. It stores the raw contribution together with the holder's balance for
+/// deferred feerate adjustment when the contribution is later reused via
+/// [`FundingTemplate::with_prior_contribution`] or [`FundingTemplate::rbf_prior_contribution`].
///
/// 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.
+/// deciding whether to reuse it or replace it with
+/// [`FundingTemplate::without_prior_contribution`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct PriorContribution {
contribution: FundingContribution,
@@ -219,29 +220,27 @@ impl PriorContribution {
///
/// # 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`] to remove funds from the channel
-/// - [`FundingTemplate::splice_in_and_out_sync`] to do both
+/// For a fresh splice (no pending splice to replace), either use the convenience methods
+/// [`FundingTemplate::splice_in_sync`] and [`FundingTemplate::splice_out`] or start with
+/// [`FundingTemplate::without_prior_contribution`] to compose a request manually.
///
-/// These require `min_feerate` and `max_feerate` parameters. The splice-in variants perform
-/// coin selection when wallet inputs are needed, while splice-out spends only from the channel
-/// balance.
+/// The builder API supports adding value, adding withdrawal outputs, or both. Attach a wallet
+/// when the request may need new wallet inputs; pure splice-out requests can be built without one
+/// and pay fees from the channel balance.
///
/// # 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.
+/// When a pending splice exists that hasn't been locked yet, use
+/// [`FundingTemplate::rbf_prior_contribution_sync`] (or
+/// [`FundingTemplate::rbf_prior_contribution`] for async) to retry the stored prior contribution
+/// at an RBF-compatible feerate. To amend that prior request before building, start from
+/// [`FundingTemplate::with_prior_contribution`] instead.
///
/// Check [`FundingTemplate::min_rbf_feerate`] for the minimum feerate required (the greater of
/// the previous feerate + 25 sat/kwu and the spec's 25/24 rule). 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.
+/// [`FundingTemplate::prior_contribution`] to inspect the stored contribution before deciding
+/// whether to reuse it or replace it with a fresh request via
+/// [`FundingTemplate::without_prior_contribution`].
///
/// [`ChannelManager::splice_channel`]: crate::ln::channelmanager::ChannelManager::splice_channel
/// [`ChannelManager::funding_contributed`]: crate::ln::channelmanager::ChannelManager::funding_contributed
@@ -271,8 +270,8 @@ impl FundingTemplate {
/// Returns the minimum RBF feerate, if this template is for an RBF attempt.
///
- /// When set, the `min_feerate` passed to the splice methods (e.g.,
- /// [`FundingTemplate::splice_in_sync`]) must be at least this value.
+ /// When set, the `min_feerate` passed to the splice/builder methods must be at least this
+ /// value.
pub fn min_rbf_feerate(&self) -> Option<FeeRate> {
self.min_rbf_feerate
}
@@ -280,16 +279,17 @@ impl FundingTemplate {
/// 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.
+ /// Use this to inspect the prior contribution's current parameters (for example,
+ /// [`FundingContribution::outputs`], [`FundingContribution::change_output`], and
+ /// [`FundingContribution::net_value`]) before deciding
+ /// whether to reuse it via [`FundingTemplate::rbf_prior_contribution`] or build a fresh
+ /// contribution with different parameters using
+ /// [`FundingTemplate::without_prior_contribution`].
///
/// 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).
+ /// the acceptor. This can change other parameters too; for example, the amount added to the
+ /// channel may increase 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)
}
@@ -320,253 +320,84 @@ impl FundingTemplate {
self.prior_contribution.take();
FundingBuilder::new(self, feerate, max_feerate)
}
-}
-
-macro_rules! build_funding_contribution {
- ($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;
-
- let _value_removed = validate_funding_contribution_params(
- value_added,
- &outputs,
- min_rbf_feerate,
- feerate,
- max_feerate,
- )?;
-
- let is_splice = shared_input.is_some();
-
- 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
- // are unknown at this point. Only needed when selecting which UTXOs to include in the
- // funding tx that would be sufficient to pay for fees. Hence, the value doesn't matter.
- let dummy_pubkey = PublicKey::from_slice(&[2; 33]).unwrap();
-
- let shared_output = bitcoin::TxOut {
- value: shared_input
- .as_ref()
- .map(|shared_input| shared_input.previous_utxo.value)
- .unwrap_or(Amount::ZERO)
- .checked_add(value_added)
- .ok_or(FundingContributionError::InvalidSpliceValue)?,
- script_pubkey: make_funding_redeemscript(&dummy_pubkey, &dummy_pubkey).to_p2wsh(),
- };
-
- let claim_id = None;
- let must_spend = shared_input.map(|input| vec![input]).unwrap_or_default();
- if outputs.is_empty() {
- let must_pay_to = &[shared_output];
- $wallet.select_confirmed_utxos(claim_id, must_spend, must_pay_to, feerate.to_sat_per_kwu() as u32, u64::MAX)$(.$await)*.map_err(|_| FundingContributionError::CoinSelectionFailed)?
- } else {
- let must_pay_to: Vec<_> = outputs.iter().cloned().chain(core::iter::once(shared_output)).collect();
- $wallet.select_confirmed_utxos(claim_id, must_spend, &must_pay_to, feerate.to_sat_per_kwu() as u32, u64::MAX)$(.$await)*.map_err(|_| FundingContributionError::CoinSelectionFailed)?
- }
- };
-
- // NOTE: Must NOT fail after UTXO selection
-
- let CoinSelection { confirmed_utxos: inputs, change_output } = coin_selection;
-
- Ok(FundingContribution::new(
- outputs,
- inputs,
- change_output,
- feerate,
- max_feerate,
- is_splice,
- ))
- }};
-}
-
-fn validate_funding_contribution_params(
- value_added: Amount, outputs: &[TxOut], min_rbf_feerate: Option<FeeRate>, feerate: FeeRate,
- max_feerate: FeeRate,
-) -> Result<Amount, FundingContributionError> {
- if feerate > max_feerate {
- return Err(FundingContributionError::FeeRateExceedsMaximum { feerate, max_feerate });
- }
-
- if let Some(min_rbf_feerate) = min_rbf_feerate {
- if feerate < min_rbf_feerate {
- return Err(FundingContributionError::FeeRateBelowRbfMinimum {
- feerate,
- min_rbf_feerate,
- });
- }
- }
- // Validate user-provided amounts are within MAX_MONEY before coin selection to
- // ensure FundingContribution::net_value() arithmetic cannot overflow. With all
- // amounts bounded by MAX_MONEY (~2.1e15 sat), the worst-case net_value()
- // computation is -2 * MAX_MONEY (~-4.2e15), well within i64::MIN (~-9.2e18).
- if value_added > Amount::MAX_MONEY {
- return Err(FundingContributionError::InvalidSpliceValue);
- }
-
- let mut value_removed = Amount::ZERO;
- for txout in outputs.iter() {
- value_removed = match value_removed.checked_add(txout.value) {
- Some(sum) if sum <= Amount::MAX_MONEY => sum,
- _ => return Err(FundingContributionError::InvalidSpliceValue),
- };
- }
-
- Ok(value_removed)
-}
-
-impl FundingTemplate {
- /// Creates a [`FundingContribution`] for adding funds to a channel using `wallet` to perform
- /// coin selection.
+ /// Creates a [`FundingContribution`] for adding funds to a channel.
+ ///
+ /// This is a convenience wrapper around [`FundingTemplate::with_prior_contribution`]. As a
+ /// result, if this template carries a prior contribution, `value_added` is added on top of the
+ /// amount that prior request was already adding to the channel instead of replacing it. Use
+ /// [`FundingTemplate::without_prior_contribution`] if you want to replace the prior request
+ /// instead.
///
- /// `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`.
+ /// `value_added` is the amount of additional value to add to the channel. `min_feerate` is the
+ /// feerate used for fee estimation and, if needed, coin selection; when
+ /// [`FundingTemplate::min_rbf_feerate`] is set, it must be at least that value. `max_feerate` is
+ /// the highest feerate we are willing to tolerate if we end up as the acceptor, and must be at
+ /// least `min_feerate`. `wallet` is only consulted if the request cannot be satisfied by
+ /// reusing/amending the prior contribution. When this template carries a prior contribution,
+ /// increasing its value may therefore re-run coin selection and yield a different input set than
+ /// the prior contribution used.
pub async fn splice_in<W: CoinSelectionSource + MaybeSend>(
self, value_added: Amount, min_feerate: FeeRate, max_feerate: FeeRate, wallet: W,
) -> Result<FundingContribution, FundingContributionError> {
- if value_added == Amount::ZERO {
- return Err(FundingContributionError::InvalidSpliceValue);
- }
- 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
- )
+ self.with_prior_contribution(min_feerate, max_feerate)
+ .with_coin_selection_source(wallet)
+ .add_value(value_added)
+ .build()
+ .await
}
- /// Creates a [`FundingContribution`] for adding funds to a channel using `wallet` to perform
- /// coin selection.
+ /// Creates a [`FundingContribution`] for adding funds to a channel.
///
- /// See [`FundingTemplate::splice_in`] for details.
+ /// This is the synchronous variant of [`FundingTemplate::splice_in`]; `value_added`,
+ /// `min_feerate`, `max_feerate`, and `wallet` have the same meaning.
pub fn splice_in_sync<W: CoinSelectionSourceSync>(
self, value_added: Amount, min_feerate: FeeRate, max_feerate: FeeRate, wallet: W,
) -> Result<FundingContribution, FundingContributionError> {
- if value_added == Amount::ZERO {
- return Err(FundingContributionError::InvalidSpliceValue);
- }
- 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,
- )
+ self.with_prior_contribution(min_feerate, max_feerate)
+ .with_coin_selection_source_sync(wallet)
+ .add_value(value_added)
+ .build()
}
/// Creates a [`FundingContribution`] for removing funds from a channel.
///
- /// Fees are paid from the channel balance, so this does not perform coin selection or spend
- /// wallet inputs.
+ /// This is a convenience wrapper around [`FundingTemplate::with_prior_contribution`] with no
+ /// wallet attached. For a fresh splice, fees are paid from the channel balance, so this does
+ /// not perform coin selection or spend wallet inputs. When a prior contribution is present,
+ /// `outputs` are appended to the prior [`FundingContribution::outputs`] instead of replacing
+ /// them. Use [`FundingTemplate::without_prior_contribution`] if you want to replace the prior
+ /// outputs instead.
+ ///
+ /// `outputs` are the additional withdrawal outputs to include. `min_feerate` is the feerate
+ /// used for fee estimation and must be at least [`FundingTemplate::min_rbf_feerate`] when that
+ /// is set. `max_feerate` is the highest feerate we are willing to tolerate if we end up as the
+ /// acceptor, and must be at least `min_feerate`.
///
- /// `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.
+ /// If amending a prior contribution would require selecting new wallet inputs, this method
+ /// returns [`FundingContributionError::MissingCoinSelectionSource`]. This can happen, for
+ /// example, when the prior contribution was input-backed and its existing change output cannot
+ /// absorb the additional withdrawal outputs or the higher fee implied by `min_feerate`. In
+ /// that case, use the builder APIs with a coin selection source instead.
pub fn splice_out(
self, outputs: Vec<TxOut>, min_feerate: FeeRate, max_feerate: FeeRate,
) -> Result<FundingContribution, FundingContributionError> {
- if outputs.is_empty() {
- return Err(FundingContributionError::InvalidSpliceValue);
- }
- validate_funding_contribution_params(
- Amount::ZERO,
- &outputs,
- self.min_rbf_feerate,
- min_feerate,
- max_feerate,
- )?;
- Ok(FundingContribution::new(
- outputs,
- vec![],
- None,
- min_feerate,
- max_feerate,
- self.shared_input.is_some(),
- ))
- }
-
- /// 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. The withdrawal `outputs` are funded by the selected wallet inputs and do
- /// not reduce the requested `value_added` to the channel.
- 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,
- ) -> Result<FundingContribution, FundingContributionError> {
- if value_added == Amount::ZERO && outputs.is_empty() {
- return Err(FundingContributionError::InvalidSpliceValue);
- }
- 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,
- ) -> Result<FundingContribution, FundingContributionError> {
- if value_added == Amount::ZERO && outputs.is_empty() {
- return Err(FundingContributionError::InvalidSpliceValue);
- }
- 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,
- )
+ self.with_prior_contribution(min_feerate, max_feerate).add_outputs(outputs).build()
}
/// 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 requires [`FundingTemplate::prior_contribution`] to be available. `feerate` overrides
+ /// the template's minimum RBF feerate; passing `None` uses
+ /// [`FundingTemplate::min_rbf_feerate`]. `max_feerate` is the highest feerate we are willing to
+ /// tolerate if we end up as the acceptor, and must be at least the effective feerate. `wallet`
+ /// is only consulted if the prior contribution cannot be reused or adjusted directly. The
+ /// chosen `max_feerate` is stored on the returned contribution so that any later acceptor-side
+ /// fee adjustment for that contribution remains capped at the caller's chosen maximum, even if
+ /// this RBF attempt had to fall back to a fresh coin selection.
///
/// This handles the prior contribution logic internally:
- /// - If the prior contribution's feerate can be adjusted to the minimum RBF feerate, the
+ /// - If the prior contribution's feerate can be adjusted to the effective target 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.
@@ -581,117 +412,41 @@ impl FundingTemplate {
///
/// # Errors
///
- /// Returns a [`FundingContributionError`] if this is not an RBF scenario, if `max_feerate`
- /// is below the minimum RBF feerate, or if coin selection fails.
- pub async fn rbf<W: CoinSelectionSource + MaybeSend>(
- self, max_feerate: FeeRate, wallet: W,
+ /// Returns a [`FundingContributionError`] if there is no reusable prior contribution, if no
+ /// effective RBF feerate is available, if the effective feerate violates the template's fee
+ /// constraints, or if coin selection fails.
+ pub async fn rbf_prior_contribution<W: CoinSelectionSource + MaybeSend>(
+ self, feerate: Option<FeeRate>, max_feerate: FeeRate, wallet: W,
) -> Result<FundingContribution, FundingContributionError> {
- let FundingTemplate { shared_input, min_rbf_feerate, prior_contribution } = self;
- let rbf_feerate = min_rbf_feerate.ok_or(FundingContributionError::NotRbfScenario)?;
- if rbf_feerate > max_feerate {
- return Err(FundingContributionError::FeeRateExceedsMaximum {
- feerate: rbf_feerate,
- max_feerate,
- });
- }
-
- 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 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
- )
- },
+ if self.prior_contribution().is_none() {
+ return Err(FundingContributionError::NotRbfScenario);
}
+ let feerate = feerate
+ .or_else(|| self.min_rbf_feerate())
+ .ok_or(FundingContributionError::NotRbfScenario)?;
+ self.with_prior_contribution(feerate, max_feerate)
+ .with_coin_selection_source(wallet)
+ .build()
+ .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,
+ /// This is the synchronous variant of [`FundingTemplate::rbf_prior_contribution`]; `feerate`,
+ /// `max_feerate`, and `wallet` have the same meaning.
+ pub fn rbf_prior_contribution_sync<W: CoinSelectionSourceSync>(
+ self, feerate: Option<FeeRate>, max_feerate: FeeRate, wallet: W,
) -> Result<FundingContribution, FundingContributionError> {
- let FundingTemplate { shared_input, min_rbf_feerate, prior_contribution } = self;
- let rbf_feerate = min_rbf_feerate.ok_or(FundingContributionError::NotRbfScenario)?;
- if rbf_feerate > max_feerate {
- return Err(FundingContributionError::FeeRateExceedsMaximum {
- feerate: rbf_feerate,
- max_feerate,
- });
+ if self.prior_contribution().is_none() {
+ return Err(FundingContributionError::NotRbfScenario);
}
+ let feerate = feerate
+ .or_else(|| self.min_rbf_feerate())
+ .ok_or(FundingContributionError::NotRbfScenario)?;
- match prior_contribution {
- Some(PriorContribution { contribution, holder_balance }) => {
- // See comment in `rbf` for details on when this adjustment fails.
- 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,
- )
- },
- }
+ self.with_prior_contribution(feerate, max_feerate)
+ .with_coin_selection_source_sync(wallet)
+ .build()
}
}
@@ -823,26 +578,6 @@ impl_writeable_tlv_based!(FundingContribution, {
});
impl FundingContribution {
- fn new(
- outputs: Vec<TxOut>, inputs: Vec<FundingTxInput>, change_output: Option<TxOut>,
- feerate: FeeRate, max_feerate: FeeRate, is_splice: bool,
- ) -> Self {
- // The caller creating a FundingContribution is always the initiator for fee estimation
- // purposes — this is conservative, overestimating rather than underestimating fees if the
- // node ends up as the acceptor.
- let estimated_fee = estimate_transaction_fee(
- &inputs,
- &outputs,
- change_output.as_ref(),
- true,
- is_splice,
- feerate,
- );
- debug_assert!(estimated_fee <= Amount::MAX_MONEY);
-
- Self { estimated_fee, inputs, outputs, change_output, feerate, max_feerate, is_splice }
- }
-
pub(super) fn feerate(&self) -> FeeRate {
self.feerate
}
@@ -1510,6 +1245,15 @@ impl FundingBuilder {
FundingBuilder(self.0.add_output_inner(output))
}
+ /// Adds withdrawal outputs to the request.
+ ///
+ /// `outputs` are appended to the current set of explicit outputs. If the builder was seeded
+ /// from a prior contribution, this adds additional withdrawals on top of the prior outputs.
+ /// This does not affect any change output derived when the contribution is built.
+ pub fn add_outputs(self, outputs: Vec<TxOut>) -> Self {
+ FundingBuilder(self.0.add_outputs_inner(outputs))
+ }
+
/// Removes all explicit withdrawal outputs whose script pubkey matches `script_pubkey`.
///
/// This only affects outputs returned by [`FundingContribution::outputs`]; it never removes the
@@ -1562,6 +1306,11 @@ impl<State> FundingBuilderInner<State> {
self
}
+ fn add_outputs_inner(mut self, outputs: Vec<TxOut>) -> Self {
+ self.outputs.extend(outputs);
+ self
+ }
+
fn remove_outputs_inner(mut self, script_pubkey: &ScriptBuf) -> Self {
self.outputs.retain(|output| output.script_pubkey != *script_pubkey);
self
@@ -1590,6 +1339,15 @@ impl<W> AsyncFundingBuilder<W> {
AsyncFundingBuilder(self.0.add_output_inner(output))
}
+ /// Adds withdrawal outputs to the request.
+ ///
+ /// `outputs` are appended to the current set of explicit outputs. If the builder was seeded
+ /// from a prior contribution, this adds additional withdrawals on top of the prior outputs.
+ /// This does not affect any change output derived when the contribution is built.
+ pub fn add_outputs(self, outputs: Vec<TxOut>) -> Self {
+ AsyncFundingBuilder(self.0.add_outputs_inner(outputs))
+ }
+
/// Removes all explicit withdrawal outputs whose script pubkey matches `script_pubkey`.
///
/// This only affects outputs returned by [`FundingContribution::outputs`]; it never removes the
@@ -1681,6 +1439,15 @@ impl<W> SyncFundingBuilder<W> {
SyncFundingBuilder(self.0.add_output_inner(output))
}
+ /// Adds withdrawal outputs to the request.
+ ///
+ /// `outputs` are appended to the current set of explicit outputs. If the builder was seeded
+ /// from a prior contribution, this adds additional withdrawals on top of the prior outputs.
+ /// This does not affect any change output derived when the contribution is built.
+ pub fn add_outputs(self, outputs: Vec<TxOut>) -> Self {
+ SyncFundingBuilder(self.0.add_outputs_inner(outputs))
+ }
+
/// Removes all explicit withdrawal outputs whose script pubkey matches `script_pubkey`.
///
/// This only affects outputs returned by [`FundingContribution::outputs`]; it never removes the
@@ -2132,38 +1899,38 @@ mod tests {
Err(FundingContributionError::InvalidSpliceValue),
));
}
+ }
- // splice_in_and_out_sync with value_added > MAX_MONEY
- {
- let template = FundingTemplate::new(None, None, None);
- let outputs = vec![funding_output_sats(1_000)];
- assert!(matches!(
- template.splice_in_and_out_sync(
- over_max,
- outputs,
- feerate,
- feerate,
- UnreachableWallet
- ),
- Err(FundingContributionError::InvalidSpliceValue),
- ));
- }
+ #[test]
+ fn test_funding_builder_validates_mixed_request_max_money() {
+ let over_max = Amount::MAX_MONEY + Amount::from_sat(1);
+ let feerate = FeeRate::from_sat_per_kwu(2000);
- // splice_in_and_out_sync with output sum > MAX_MONEY
- {
- let template = FundingTemplate::new(None, None, None);
- let outputs = vec![funding_output_sats(over_max.to_sat())];
- assert!(matches!(
- template.splice_in_and_out_sync(
- Amount::from_sat(1_000),
- outputs,
- feerate,
- feerate,
- UnreachableWallet,
- ),
- Err(FundingContributionError::InvalidSpliceValue),
- ));
- }
+ // Mixed add/remove request with value_added > MAX_MONEY.
+ assert!(matches!(
+ FundingTemplate::new(None, None, None)
+ .without_prior_contribution(feerate, feerate)
+ .with_coin_selection_source_sync(UnreachableWallet)
+ .add_value(over_max)
+ .add_outputs(vec![funding_output_sats(1_000)])
+ .build(),
+ Err(FundingContributionError::InvalidSpliceValue),
+ ));
+
+ // Mixed add/remove request with outputs summing > MAX_MONEY.
+ let half_over = Amount::MAX_MONEY / 2 + Amount::from_sat(1);
+ assert!(matches!(
+ FundingTemplate::new(None, None, None)
+ .without_prior_contribution(feerate, feerate)
+ .with_coin_selection_source_sync(UnreachableWallet)
+ .add_value(Amount::from_sat(1_000))
+ .add_outputs(vec![
+ funding_output_sats(half_over.to_sat()),
+ funding_output_sats(half_over.to_sat()),
+ ])
+ .build(),
+ Err(FundingContributionError::InvalidSpliceValue),
+ ));
}
#[test]
@@ -2934,9 +2701,9 @@ mod tests {
}
#[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(()).
+ fn test_rbf_rejects_max_feerate_below_min_rbf_feerate() {
+ // When the caller's max_feerate is below the minimum RBF feerate,
+ // rbf_prior_contribution_sync should return an error.
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(2020);
@@ -2958,15 +2725,16 @@ mod tests {
Some(PriorContribution::new(prior, Amount::MAX)),
);
assert!(matches!(
- template.rbf_sync(max_feerate, UnreachableWallet),
+ template.rbf_prior_contribution_sync(None, max_feerate, UnreachableWallet),
Err(FundingContributionError::FeeRateExceedsMaximum { .. }),
));
}
#[test]
- fn test_rbf_sync_adjusts_prior_to_rbf_feerate() {
+ fn test_rbf_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.
+ // balance is available, rbf_prior_contribution_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);
@@ -2991,11 +2759,109 @@ mod tests {
Some(min_rbf_feerate),
Some(PriorContribution::new(prior, Amount::MAX)),
);
- let contribution = template.rbf_sync(max_feerate, UnreachableWallet).unwrap();
+ let contribution =
+ template.rbf_prior_contribution_sync(None, max_feerate, UnreachableWallet).unwrap();
assert_eq!(contribution.feerate, min_rbf_feerate);
assert_eq!(contribution.max_feerate, max_feerate);
}
+ #[test]
+ fn test_rbf_uses_explicit_override_feerate() {
+ let prior_feerate = FeeRate::from_sat_per_kwu(2000);
+ let min_rbf_feerate = FeeRate::from_sat_per_kwu(2025);
+ let override_feerate = FeeRate::from_sat_per_kwu(2100);
+ 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 {
+ 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, Amount::MAX)),
+ );
+ let contribution = template
+ .rbf_prior_contribution_sync(Some(override_feerate), max_feerate, UnreachableWallet)
+ .unwrap();
+ assert_eq!(contribution.feerate, override_feerate);
+ assert_eq!(contribution.max_feerate, max_feerate);
+ }
+
+ #[test]
+ fn test_rbf_rejects_explicit_override_below_min_rbf_feerate() {
+ let prior_feerate = FeeRate::from_sat_per_kwu(2000);
+ let min_rbf_feerate = FeeRate::from_sat_per_kwu(2025);
+ let override_feerate = FeeRate::from_sat_per_kwu(2024);
+
+ let prior = FundingContribution {
+ 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,
+ };
+
+ let template = FundingTemplate::new(
+ None,
+ Some(min_rbf_feerate),
+ Some(PriorContribution::new(prior, Amount::MAX)),
+ );
+ assert!(matches!(
+ template.rbf_prior_contribution_sync(
+ Some(override_feerate),
+ FeeRate::MAX,
+ UnreachableWallet,
+ ),
+ Err(FundingContributionError::FeeRateBelowRbfMinimum { .. }),
+ ));
+ }
+
+ #[test]
+ fn test_rbf_rejects_explicit_override_above_max_feerate() {
+ let prior_feerate = FeeRate::from_sat_per_kwu(2000);
+ let min_rbf_feerate = FeeRate::from_sat_per_kwu(2025);
+ let override_feerate = FeeRate::from_sat_per_kwu(2100);
+ let max_feerate = FeeRate::from_sat_per_kwu(2099);
+
+ let prior = FundingContribution {
+ 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,
+ };
+
+ let template = FundingTemplate::new(
+ None,
+ Some(min_rbf_feerate),
+ Some(PriorContribution::new(prior, Amount::MAX)),
+ );
+ assert!(matches!(
+ template.rbf_prior_contribution_sync(
+ Some(override_feerate),
+ max_feerate,
+ UnreachableWallet,
+ ),
+ Err(FundingContributionError::FeeRateExceedsMaximum { .. }),
+ ));
+ }
+
/// A mock wallet that returns a single UTXO for coin selection.
struct SingleUtxoWallet {
utxo: FundingTxInput,
@@ -3029,10 +2895,10 @@ mod tests {
}
#[test]
- fn test_rbf_sync_unadjusted_splice_out_runs_coin_selection() {
+ fn test_rbf_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.
+ // holder balance is available, rbf_prior_contribution_sync should run coin selection to
+ // add inputs that cover the higher RBF fee.
let prior_feerate = FeeRate::from_sat_per_kwu(2000);
let min_rbf_feerate = FeeRate::from_sat_per_kwu(2025);
let withdrawal = funding_output_sats(20_000);
@@ -3058,8 +2924,10 @@ mod tests {
change_output: Some(funding_output_sats(25_000)),
};
- // rbf_sync should succeed and the contribution should have inputs from coin selection.
- let contribution = template.rbf_sync(FeeRate::MAX, &wallet).unwrap();
+ // rbf_prior_contribution_sync should succeed and the contribution should have inputs from
+ // coin selection.
+ let contribution =
+ template.rbf_prior_contribution_sync(None, FeeRate::MAX, &wallet).unwrap();
assert!(!contribution.inputs.is_empty(), "coin selection should have added inputs");
assert!(contribution.value_added() > Amount::ZERO);
assert_eq!(contribution.outputs, vec![withdrawal]);
@@ -3067,31 +2935,10 @@ mod tests {
}
#[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(2025);
-
- 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!(!contribution.inputs.is_empty(), "coin selection should have added inputs");
- assert!(contribution.value_added() > Amount::ZERO);
- assert!(contribution.outputs.is_empty());
- assert_eq!(contribution.feerate, min_rbf_feerate);
- }
-
- #[test]
- fn test_rbf_sync_unadjusted_uses_callers_max_feerate() {
+ fn test_rbf_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.
+ // holder balance is available, rbf_prior_contribution_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(2025);
let prior_max_feerate = FeeRate::from_sat_per_kwu(50_000);
let callers_max_feerate = FeeRate::from_sat_per_kwu(10_000);
@@ -3118,7 +2965,8 @@ mod tests {
change_output: Some(funding_output_sats(25_000)),
};
- let contribution = template.rbf_sync(callers_max_feerate, &wallet).unwrap();
+ let contribution =
+ template.rbf_prior_contribution_sync(None, callers_max_feerate, &wallet).unwrap();
assert_eq!(
contribution.max_feerate, callers_max_feerate,
"should use caller's max_feerate, not prior's"
@@ -3127,8 +2975,9 @@ mod tests {
#[test]
fn test_splice_out_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.
+ // When splice_out is called on a template with min_rbf_feerate set (user choosing a
+ // fresh splice-out instead of rbf_prior_contribution_sync), coin selection should NOT
+ // run.
// Fees come from the channel balance.
let min_rbf_feerate = FeeRate::from_sat_per_kwu(2025);
let feerate = FeeRate::from_sat_per_kwu(2025);
diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs
index 6cec7a4..1d4da39 100644
--- a/lightning/src/ln/splicing_tests.rs
+++ b/lightning/src/ln/splicing_tests.rs
@@ -20,7 +20,7 @@ use crate::ln::channel::{
};
use crate::ln::channelmanager::{provided_init_features, PaymentId, BREAKDOWN_TIMEOUT};
use crate::ln::functional_test_utils::*;
-use crate::ln::funding::FundingContribution;
+use crate::ln::funding::{FundingContribution, FundingContributionError};
use crate::ln::msgs::{self, BaseMessageHandler, ChannelMessageHandler, MessageSendEvent};
use crate::ln::outbound_payment::RecipientOnionFields;
use crate::ln::types::ChannelId;
@@ -41,7 +41,7 @@ use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey};
use bitcoin::transaction::Version;
use bitcoin::{
Amount, FeeRate, OutPoint as BitcoinOutPoint, Psbt, ScriptBuf, Transaction, TxOut, Txid,
- WPubkeyHash,
+ WPubkeyHash, WScriptHash,
};
#[test]
@@ -221,7 +221,11 @@ pub fn do_initiate_rbf_splice_in_and_out<'a, 'b, 'c, 'd>(
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)
+ .without_prior_contribution(feerate, FeeRate::MAX)
+ .with_coin_selection_source_sync(&wallet)
+ .add_value(value_added)
+ .add_outputs(outputs)
+ .build()
.unwrap();
node.node
.funding_contributed(&channel_id, &node_id_counterparty, funding_contribution.clone(), None)
@@ -269,7 +273,11 @@ pub fn do_initiate_splice_in_and_out<'a, 'b, 'c, 'd>(
let feerate = funding_template.min_rbf_feerate().unwrap_or(floor_feerate);
let wallet = WalletSync::new(Arc::clone(&initiator.wallet_source), initiator.logger);
let funding_contribution = funding_template
- .splice_in_and_out_sync(value_added, outputs, feerate, FeeRate::MAX, &wallet)
+ .without_prior_contribution(feerate, FeeRate::MAX)
+ .with_coin_selection_source_sync(&wallet)
+ .add_value(value_added)
+ .add_outputs(outputs)
+ .build()
.unwrap();
initiator
.node
@@ -3576,7 +3584,7 @@ fn test_funding_contributed_splice_already_pending() {
let splice_in_amount = Amount::from_sat(20_000);
provide_utxo_reserves(&nodes, 2, splice_in_amount * 2);
- // Use splice_in_and_out with an output so we can test output filtering
+ // Use the contribution builder with an output so we can test output filtering
let first_splice_out = TxOut {
value: Amount::from_sat(5_000),
script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::from_raw_hash(Hash::all_zeros())),
@@ -3585,13 +3593,11 @@ fn test_funding_contributed_splice_already_pending() {
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 first_contribution = funding_template
- .splice_in_and_out_sync(
- splice_in_amount,
- vec![first_splice_out.clone()],
- feerate,
- FeeRate::MAX,
- &wallet,
- )
+ .with_prior_contribution(feerate, FeeRate::MAX)
+ .with_coin_selection_source_sync(&wallet)
+ .add_value(splice_in_amount)
+ .add_output(first_splice_out.clone())
+ .build()
.unwrap();
// Initiate a second splice with a DIFFERENT output to test that different outputs
@@ -3613,13 +3619,11 @@ fn test_funding_contributed_splice_already_pending() {
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 second_contribution = funding_template
- .splice_in_and_out_sync(
- splice_in_amount,
- vec![second_splice_out.clone()],
- feerate,
- FeeRate::MAX,
- &wallet,
- )
+ .without_prior_contribution(feerate, FeeRate::MAX)
+ .with_coin_selection_source_sync(&wallet)
+ .add_value(splice_in_amount)
+ .add_output(second_splice_out.clone())
+ .build()
.unwrap();
// First funding_contributed - this sets up the quiescent action
@@ -5429,7 +5433,8 @@ fn test_splice_rbf_after_counterparty_rbf_aborted() {
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.
+ // Step 5: Node 1 initiates its own RBF via splice_channel →
+ // rbf_prior_contribution_sync.
// The prior contribution's feerate is restored to the original floor feerate, not the
// RBF-adjusted feerate.
provide_utxo_reserves(&nodes, 2, added_value * 2);
@@ -5443,7 +5448,8 @@ fn test_splice_rbf_after_counterparty_rbf_aborted() {
);
let wallet = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger);
- let rbf_contribution = funding_template.rbf_sync(FeeRate::MAX, &wallet);
+ let rbf_contribution =
+ funding_template.rbf_prior_contribution_sync(None, FeeRate::MAX, &wallet);
assert!(rbf_contribution.is_ok());
}
@@ -5644,6 +5650,235 @@ fn test_splice_rbf_sequential() {
);
}
+#[test]
+fn test_splice_rbf_amends_prior_net_positive_contribution_request() {
+ 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 (_, _, channel_id, _) =
+ create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 0);
+
+ let initial_added_value = Amount::from_sat(100_000);
+ let half_added_value = Amount::from_sat(initial_added_value.to_sat() / 2);
+ provide_utxo_reserves(&nodes, 1, Amount::from_sat(250_000));
+
+ let initial_contribution =
+ do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, initial_added_value);
+ let (initial_inputs, _) = initial_contribution.clone().into_contributed_inputs_and_outputs();
+ let (splice_tx_0, new_funding_script) =
+ splice_channel(&nodes[0], &nodes[1], channel_id, initial_contribution.clone());
+
+ let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger);
+ let first_output = TxOut {
+ value: Amount::from_sat(10_000),
+ script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::from_raw_hash(Hash::all_zeros())),
+ };
+ let second_output = TxOut {
+ value: Amount::from_sat(15_000),
+ script_pubkey: ScriptBuf::new_p2wsh(&WScriptHash::all_zeros()),
+ };
+
+ let run_rbf_round = |contribution: FundingContribution| {
+ nodes[0]
+ .node
+ .funding_contributed(&channel_id, &node_id_1, contribution.clone(), None)
+ .unwrap();
+ complete_rbf_handshake(&nodes[0], &nodes[1]);
+ complete_interactive_funding_negotiation(
+ &nodes[0],
+ &nodes[1],
+ channel_id,
+ contribution,
+ new_funding_script.clone(),
+ );
+ let (tx, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], false);
+ assert!(splice_locked.is_none());
+ expect_splice_pending_event(&nodes[0], &node_id_1);
+ expect_splice_pending_event(&nodes[1], &node_id_0);
+ tx
+ };
+
+ let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap();
+ assert!(funding_template.prior_contribution().unwrap().outputs().is_empty());
+ let rbf_feerate = funding_template.min_rbf_feerate().unwrap();
+ let contribution_1 = funding_template
+ .splice_out(vec![first_output.clone(), second_output.clone()], rbf_feerate, FeeRate::MAX)
+ .unwrap();
+ let (inputs_1, _) = contribution_1.clone().into_contributed_inputs_and_outputs();
+ assert_eq!(inputs_1, initial_inputs);
+ assert_eq!(contribution_1.outputs(), &[first_output.clone(), second_output.clone()]);
+ assert_eq!(contribution_1.net_value(), initial_contribution.net_value());
+ assert!(
+ contribution_1.change_output().unwrap().value
+ < initial_contribution.change_output().unwrap().value
+ );
+ let splice_tx_1 = run_rbf_round(contribution_1.clone());
+
+ let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap();
+ assert_eq!(funding_template.prior_contribution().unwrap().outputs(), contribution_1.outputs());
+ let rbf_feerate = funding_template.min_rbf_feerate().unwrap();
+ let contribution_2 = funding_template
+ .with_prior_contribution(rbf_feerate, FeeRate::MAX)
+ .with_coin_selection_source_sync(&wallet)
+ .remove_value(half_added_value)
+ .build()
+ .unwrap();
+ let (inputs_2, _) = contribution_2.clone().into_contributed_inputs_and_outputs();
+ assert_eq!(inputs_2, initial_inputs);
+ assert_eq!(contribution_2.outputs(), contribution_1.outputs());
+ assert!(contribution_2.net_value() < contribution_1.net_value());
+ let splice_tx_2 = run_rbf_round(contribution_2.clone());
+
+ let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap();
+ assert_eq!(funding_template.prior_contribution().unwrap().outputs(), contribution_2.outputs());
+ let rbf_feerate = funding_template.min_rbf_feerate().unwrap();
+ let contribution_3 = funding_template
+ .with_prior_contribution(rbf_feerate, FeeRate::MAX)
+ .remove_outputs(&first_output.script_pubkey)
+ .build()
+ .unwrap();
+ let (inputs_3, _) = contribution_3.clone().into_contributed_inputs_and_outputs();
+ assert_eq!(inputs_3, initial_inputs);
+ assert_eq!(contribution_3.outputs(), std::slice::from_ref(&second_output));
+ assert_eq!(contribution_3.net_value(), contribution_2.net_value());
+ assert!(
+ contribution_3.change_output().unwrap().value
+ > contribution_2.change_output().unwrap().value
+ );
+ let splice_tx_3 = run_rbf_round(contribution_3.clone());
+
+ let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap();
+ assert_eq!(funding_template.prior_contribution().unwrap().outputs(), contribution_3.outputs());
+ let contribution_4 =
+ funding_template.rbf_prior_contribution_sync(None, FeeRate::MAX, &wallet).unwrap();
+ let (inputs_4, _) = contribution_4.clone().into_contributed_inputs_and_outputs();
+ assert_eq!(inputs_4, initial_inputs);
+ assert_eq!(contribution_4.outputs(), contribution_3.outputs());
+ assert_eq!(contribution_4.net_value(), contribution_3.net_value());
+ assert!(
+ contribution_4.change_output().unwrap().value
+ < contribution_3.change_output().unwrap().value
+ );
+ let rbf_tx_final = run_rbf_round(contribution_4);
+
+ lock_rbf_splice_after_blocks(
+ &nodes[0],
+ &nodes[1],
+ &rbf_tx_final,
+ ANTI_REORG_DELAY - 1,
+ &[
+ splice_tx_0.compute_txid(),
+ splice_tx_1.compute_txid(),
+ splice_tx_2.compute_txid(),
+ splice_tx_3.compute_txid(),
+ ],
+ );
+}
+
+#[test]
+fn test_splice_rbf_amends_prior_net_negative_contribution_request() {
+ 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 (_, _, channel_id, _) =
+ create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 0);
+
+ let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger);
+ let first_output = TxOut {
+ value: Amount::from_sat(10_000),
+ script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::from_raw_hash(Hash::all_zeros())),
+ };
+ let second_output = TxOut {
+ value: Amount::from_sat(15_000),
+ script_pubkey: ScriptBuf::new_p2wsh(&WScriptHash::all_zeros()),
+ };
+
+ let initial_contribution =
+ initiate_splice_out(&nodes[0], &nodes[1], channel_id, vec![first_output.clone()]).unwrap();
+ let (initial_inputs, _) = initial_contribution.clone().into_contributed_inputs_and_outputs();
+ assert!(initial_inputs.is_empty());
+ let (splice_tx_0, new_funding_script) =
+ splice_channel(&nodes[0], &nodes[1], channel_id, initial_contribution.clone());
+
+ let run_rbf_round = |contribution: FundingContribution| {
+ nodes[0]
+ .node
+ .funding_contributed(&channel_id, &node_id_1, contribution.clone(), None)
+ .unwrap();
+ complete_rbf_handshake(&nodes[0], &nodes[1]);
+ complete_interactive_funding_negotiation(
+ &nodes[0],
+ &nodes[1],
+ channel_id,
+ contribution,
+ new_funding_script.clone(),
+ );
+ let (tx, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], false);
+ assert!(splice_locked.is_none());
+ expect_splice_pending_event(&nodes[0], &node_id_1);
+ expect_splice_pending_event(&nodes[1], &node_id_0);
+ tx
+ };
+
+ let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap();
+ assert_eq!(
+ funding_template.prior_contribution().unwrap().outputs(),
+ std::slice::from_ref(&first_output),
+ );
+ let rbf_feerate = funding_template.min_rbf_feerate().unwrap();
+ let contribution_1 = funding_template
+ .splice_out(vec![second_output.clone()], rbf_feerate, FeeRate::MAX)
+ .unwrap();
+ let (inputs_1, _) = contribution_1.clone().into_contributed_inputs_and_outputs();
+ assert!(inputs_1.is_empty());
+ assert_eq!(contribution_1.outputs(), &[first_output.clone(), second_output.clone()]);
+ assert!(contribution_1.net_value() < initial_contribution.net_value());
+ let splice_tx_1 = run_rbf_round(contribution_1.clone());
+
+ let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap();
+ assert_eq!(funding_template.prior_contribution().unwrap().outputs(), contribution_1.outputs());
+ let rbf_feerate = funding_template.min_rbf_feerate().unwrap();
+ let contribution_2 = funding_template
+ .with_prior_contribution(rbf_feerate, FeeRate::MAX)
+ .remove_outputs(&first_output.script_pubkey)
+ .build()
+ .unwrap();
+ let (inputs_2, _) = contribution_2.clone().into_contributed_inputs_and_outputs();
+ assert!(inputs_2.is_empty());
+ assert_eq!(contribution_2.outputs(), std::slice::from_ref(&second_output));
+ assert!(contribution_2.net_value() > contribution_1.net_value());
+ let splice_tx_2 = run_rbf_round(contribution_2.clone());
+
+ let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap();
+ assert_eq!(funding_template.prior_contribution().unwrap().outputs(), contribution_2.outputs());
+ let contribution_3 =
+ funding_template.rbf_prior_contribution_sync(None, FeeRate::MAX, &wallet).unwrap();
+ let (inputs_3, _) = contribution_3.clone().into_contributed_inputs_and_outputs();
+ assert!(inputs_3.is_empty());
+ assert_eq!(contribution_3.outputs(), contribution_2.outputs());
+ assert!(contribution_3.net_value() < contribution_2.net_value());
+ assert!(contribution_3.change_output().is_none());
+ let rbf_tx_final = run_rbf_round(contribution_3);
+
+ lock_rbf_splice_after_blocks(
+ &nodes[0],
+ &nodes[1],
+ &rbf_tx_final,
+ ANTI_REORG_DELAY - 1,
+ &[splice_tx_0.compute_txid(), splice_tx_1.compute_txid(), splice_tx_2.compute_txid()],
+ );
+}
+
#[test]
fn test_splice_rbf_acceptor_contributes_then_disconnects() {
// When both nodes contribute to a splice and the initiator RBFs (with the acceptor
@@ -5896,9 +6131,9 @@ fn test_splice_channel_with_pending_splice_includes_rbf_floor() {
assert_eq!(funding_template.min_rbf_feerate(), Some(expected_floor));
assert!(funding_template.prior_contribution().is_some());
- // rbf_sync returns the Adjusted prior contribution directly.
+ // rbf_prior_contribution_sync returns the adjusted prior contribution directly.
let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger);
- assert!(funding_template.rbf_sync(FeeRate::MAX, &wallet).is_ok());
+ assert!(funding_template.rbf_prior_contribution_sync(None, FeeRate::MAX, &wallet).is_ok());
}
#[test]
@@ -6099,8 +6334,8 @@ fn test_funding_contributed_rbf_adjustment_insufficient_budget() {
#[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.
+ // Test that rbf_prior_contribution_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]);
@@ -6130,13 +6365,13 @@ fn test_prior_contribution_unadjusted_when_max_feerate_too_low() {
let (_splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution);
// Call splice_channel again — the minimum RBF feerate (floor + 25 sat/kwu) exceeds the prior
- // contribution's max_feerate (floor), so adjustment fails. rbf_sync re-runs coin selection
- // with the caller's max_feerate.
+ // contribution's max_feerate (floor), so adjustment fails.
+ // rbf_prior_contribution_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());
+ assert!(funding_template.rbf_prior_contribution_sync(None, FeeRate::MAX, &wallet).is_ok());
}
#[test]
@@ -6177,17 +6412,19 @@ fn test_splice_channel_during_negotiation_includes_rbf_feerate() {
let expected_floor = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64 + 25);
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.
+ // No prior contribution since there are no negotiated candidates yet, so RBF is rejected.
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());
+ assert!(matches!(
+ template.rbf_prior_contribution_sync(None, FeeRate::MAX, &wallet),
+ Err(FundingContributionError::NotRbfScenario)
+ ));
}
#[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.
+ // Test that rbf_prior_contribution_sync returns `NotRbfScenario` when there is no pending
+ // splice (min_rbf_feerate is None).
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]);
@@ -6209,15 +6446,15 @@ fn test_rbf_sync_returns_err_when_no_min_rbf_feerate() {
let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger);
assert!(matches!(
- template.rbf_sync(FeeRate::MAX, &wallet),
+ template.rbf_prior_contribution_sync(None, FeeRate::MAX, &wallet),
Err(crate::ln::funding::FundingContributionError::NotRbfScenario),
));
}
#[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.
+ // Test that rbf_prior_contribution_sync returns an error 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]);
@@ -6245,7 +6482,7 @@ fn test_rbf_sync_returns_err_when_max_feerate_below_min_rbf() {
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!(matches!(
- funding_template.rbf_sync(too_low_feerate, &wallet),
+ funding_template.rbf_prior_contribution_sync(None, too_low_feerate, &wallet),
Err(crate::ln::funding::FundingContributionError::FeeRateExceedsMaximum { .. }),
));
}
@@ -6516,9 +6753,12 @@ fn test_splice_rbf_rejects_own_low_feerate_after_several_attempts() {
let rbf_feerate = FeeRate::from_sat_per_kwu(next_feerate);
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 contribution =
- funding_template.splice_in_sync(added_value, rbf_feerate, FeeRate::MAX, &wallet).unwrap();
-
+ let contribution = funding_template
+ .without_prior_contribution(rbf_feerate, FeeRate::MAX)
+ .with_coin_selection_source_sync(&wallet)
+ .add_value(added_value)
+ .build()
+ .unwrap();
let result = nodes[0].node.funding_contributed(&channel_id, &node_id_1, contribution, None);
assert!(result.is_err(), "Expected rejection for low feerate: {:?}", result);
Why this scored 27/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.