Introduce FundingBuilder for splice requests
What changed, and why it matters
This commit refactors how Bitcoin Lightning channel 'splice' funding requests are built. It introduces a FundingBuilder API that lets callers amend an earlier contribution (for example, changing the amount or adding a withdrawal output) without re-running wallet coin selection when the existing inputs can still cover the new request. The change also moves prevtx-size validation into the builder and adds explicit error variants for missing coin-selection sources and oversized previous transactions. There is no direct evidence in the commit or supplied references that this fixes a known security vulnerability; it reads as a feature/refactoring change with defensive hardening.
Treat as a normal code-review item. Verify that removing the explicit validate() call in channel.rs does not allow an oversized prevtx to reach the interactive transaction protocol, since the builder now performs equivalent validation. Confirm that all paths constructing a FundingContribution for splicing go through FundingBuilder or otherwise call validate_inputs(). Review the new error handling to ensure MissingCoinSelectionSource is returned consistently instead of silently proceeding without inputs.
Security signals we found
Removed explicit FundingContribution::validate() call from splice contribution handling in channel.rs; validation now happens inside FundingBuilder/validate_inputs.
Added FundingContributionError::PrevTxTooLarge to enforce LN_MAX_MSG_LEN on tx_add_input prevtx serialization.
Added FundingContributionError::MissingCoinSelectionSource to fail closed when wallet inputs are required but no source is attached.
Refactored splice-in/splice-out request construction to reuse prior inputs when possible, reducing repeated coin-selection surface.
No vendor security disclosure, CVE, or researcher attribution present in commit or supplied references.
Evidence from the diff
The patch adds a FundingBuilder, AsyncFundingBuilder, and SyncFundingBuilder in lightning/src/ln/funding.rs, plus helper methods on FundingTemplate. It removes FundingContribution::validate() and replaces it with validate_inputs(), returning FundingContributionError::PrevTxTooLarge when a funding input’s prevtx would exceed LN_MAX_MSG_LEN in a tx_add_input message. It also removes the .validate() call sites in channel.rs, relying on the builder to validate inputs. The builder supports amending a prior contribution in place (adjusting change output, dropping sub-dust change, retargeting feerate) and falls back to fresh coin selection only when a wallet source is attached and needed. New error variants include MissingCoinSelectionSource and PrevTxTooLarge. The channel.rs changes simply compute net_value() before calling validate_splice_contributions() instead of chaining validate().
Changed components
lightning/src/ln/funding.rslightning/src/ln/channel.rsFundingContributionFundingTemplateFundingBuilder / AsyncFundingBuilder / SyncFundingBuilderSplice request handling in ChannelInspect captured patch +925 / −41
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index b99b2a1..c9301ac 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -12535,14 +12535,12 @@ where
};
}
- if let Err(e) = contribution.validate().and_then(|()| {
- // For splice-out, our_funding_contribution is adjusted to cover fees if there
- // aren't any inputs.
- let our_funding_contribution = contribution.net_value();
+ let our_funding_contribution = contribution.net_value();
+
+ if let Err(e) =
self.validate_splice_contributions(our_funding_contribution, SignedAmount::ZERO)
- }) {
+ {
log_error!(logger, "Channel {} cannot be funded: {}", self.context.channel_id(), e);
-
return Err(QuiescentError::FailSplice(self.splice_funding_failed_for(contribution)));
}
@@ -14104,13 +14102,11 @@ where
// funding_contributed and quiescence, reducing the holder's
// balance. If invalid, disconnect and return the contribution so
// the user can reclaim their inputs.
- if let Err(e) = contribution.validate().and_then(|()| {
- let our_funding_contribution = contribution.net_value();
- self.validate_splice_contributions(
- our_funding_contribution,
- SignedAmount::ZERO,
- )
- }) {
+ let our_funding_contribution = contribution.net_value();
+ if let Err(e) = self.validate_splice_contributions(
+ our_funding_contribution,
+ SignedAmount::ZERO,
+ ) {
let failed = self.splice_funding_failed_for(contribution);
return Err((
ChannelError::WarnAndDisconnect(format!(
diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs
index 31878e3..b628f54 100644
--- a/lightning/src/ln/funding.rs
+++ b/lightning/src/ln/funding.rs
@@ -133,8 +133,19 @@ pub enum FundingContributionError {
},
/// The splice value is invalid (zero, empty outputs, or exceeds the maximum money supply).
InvalidSpliceValue,
+ /// An input's `prevtx` is too large to fit in a `tx_add_input` message.
+ PrevTxTooLarge,
/// Coin selection failed to find suitable inputs.
CoinSelectionFailed,
+ /// Coin selection is required but no coin selection source was provided.
+ ///
+ /// This can also be returned when reusing a prior contribution would otherwise satisfy the
+ /// request, but that prior contribution cannot be adjusted in-place to the requested feerate.
+ /// For example, an input-backed prior contribution may no longer have enough fee buffer in its
+ /// change output to absorb the higher fee. In that case, providing a coin selection source lets
+ /// 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).
NotRbfScenario,
}
@@ -151,9 +162,15 @@ impl core::fmt::Display for FundingContributionError {
FundingContributionError::InvalidSpliceValue => {
write!(f, "Invalid splice value (zero, empty, or exceeds limit)")
},
+ FundingContributionError::PrevTxTooLarge => {
+ write!(f, "Input prevtx is too large to fit in a tx_add_input message")
+ },
FundingContributionError::CoinSelectionFailed => {
write!(f, "Coin selection failed to find suitable inputs")
},
+ FundingContributionError::MissingCoinSelectionSource => {
+ write!(f, "Coin selection source required to build this contribution")
+ },
FundingContributionError::NotRbfScenario => {
write!(f, "Not an RBF scenario (no minimum RBF feerate)")
},
@@ -276,6 +293,33 @@ impl FundingTemplate {
pub fn prior_contribution(&self) -> Option<&FundingContribution> {
self.prior_contribution.as_ref().map(|p| &p.contribution)
}
+
+ /// Creates a [`FundingBuilder`] for constructing a contribution.
+ ///
+ /// If a prior contribution is available, the builder starts from it automatically and builder
+ /// mutations amend that prior request. Use [`FundingTemplate::without_prior_contribution`] to
+ /// start empty instead.
+ ///
+ /// `feerate` is the feerate used for fee estimation and, if wallet inputs are 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 `feerate`.
+ pub fn with_prior_contribution(self, feerate: FeeRate, max_feerate: FeeRate) -> FundingBuilder {
+ FundingBuilder::new(self, feerate, max_feerate)
+ }
+
+ /// Creates a [`FundingBuilder`] for constructing a contribution without using any prior
+ /// contribution.
+ ///
+ /// `feerate` and `max_feerate` have the same meaning as in
+ /// [`FundingTemplate::with_prior_contribution`]. This is useful when an RBF template carries a
+ /// prior contribution but the caller wants to replace, rather than amend, that request.
+ pub fn without_prior_contribution(
+ mut self, feerate: FeeRate, max_feerate: FeeRate,
+ ) -> FundingBuilder {
+ self.prior_contribution.take();
+ FundingBuilder::new(self, feerate, max_feerate)
+ }
}
macro_rules! build_funding_contribution {
@@ -701,6 +745,44 @@ fn estimate_transaction_fee(
Weight::from_wu(weight) * feerate
}
+fn validate_inputs(inputs: &[FundingTxInput]) -> Result<(), FundingContributionError> {
+ let mut total_value = Amount::ZERO;
+ for input in inputs {
+ use crate::util::ser::Writeable;
+ const MESSAGE_TEMPLATE: msgs::TxAddInput = msgs::TxAddInput {
+ channel_id: ChannelId([0; 32]),
+ serial_id: 0,
+ prevtx: None,
+ prevtx_out: 0,
+ sequence: 0,
+ // Mutually exclusive with prevtx, which is accounted for below.
+ shared_input_txid: None,
+ };
+ let message_len = MESSAGE_TEMPLATE.serialized_length() + input.prevtx.serialized_length();
+ (message_len <= LN_MAX_MSG_LEN)
+ .then(|| ())
+ .ok_or(FundingContributionError::PrevTxTooLarge)?;
+
+ total_value = match total_value.checked_add(input.utxo.output.value) {
+ Some(sum) if sum <= Amount::MAX_MONEY => sum,
+ _ => return Err(FundingContributionError::InvalidSpliceValue),
+ };
+ }
+
+ Ok(())
+}
+
+/// Describes how an amended contribution should source its wallet-backed inputs.
+enum FundingInputs {
+ None,
+ /// Reuses the contribution's existing inputs while targeting at least `value_added` added to
+ /// the channel after fees. If dropping the change output leaves surplus value, it remains in
+ /// the channel contribution.
+ CoinSelected {
+ value_added: Amount,
+ },
+}
+
/// The components of a funding transaction contributed by one party.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FundingContribution {
@@ -808,6 +890,105 @@ impl FundingContribution {
self.change_output.as_ref()
}
+ /// Tries to satisfy a new request using only this contribution's existing inputs.
+ ///
+ /// For input-backed contributions, this reuses the current inputs, adjusts the explicit
+ /// outputs, and shrinks or drops the change output as needed before applying
+ /// `target_feerate`. If dropping change leaves surplus value, that surplus remains in the
+ /// channel contribution.
+ ///
+ /// For input-less contributions, `holder_balance` must be provided to cover the outputs and
+ /// fees from the channel balance.
+ ///
+ /// Returns `None` if the request would require new wallet inputs or cannot accommodate the
+ /// requested feerate.
+ fn amend_without_coin_selection(
+ self, inputs: FundingInputs, outputs: &[TxOut], target_feerate: FeeRate,
+ max_feerate: FeeRate, holder_balance: Amount,
+ ) -> Option<Self> {
+ // NOTE: The contribution returned is not guaranteed to be valid. We defer doing so until
+ // `compute_feerate_adjustment`.
+ let adjust_for_inputs_and_outputs =
+ |contribution: Self, inputs: FundingInputs, outputs: &[TxOut]| -> Option<Self> {
+ let (target_value_added, inputs) = match inputs {
+ FundingInputs::None => (None, Vec::new()),
+ FundingInputs::CoinSelected { value_added } => {
+ (Some(value_added), contribution.inputs)
+ },
+ };
+
+ if inputs.is_empty() && target_value_added.unwrap_or(Amount::ZERO) != Amount::ZERO {
+ // Prior contribution didn't have any inputs, but now we need some.
+ return None;
+ }
+
+ // When inputs are coin-selected, adjust the existing change output, if any, to account
+ // for the requested value added and any explicit outputs that must also be funded by
+ // the inputs.
+ if let Some(value_added) = target_value_added {
+ let estimated_fee = estimate_transaction_fee(
+ &inputs,
+ &outputs,
+ contribution.change_output.as_ref(),
+ true,
+ contribution.is_splice,
+ contribution.feerate,
+ );
+ let total_output_value: Amount =
+ outputs.iter().map(|output| output.value).sum();
+ let required_value =
+ value_added.checked_add(total_output_value)?.checked_add(estimated_fee)?;
+
+ if let Some(change_output) = contribution.change_output.as_ref() {
+ let dust_limit = change_output.script_pubkey.minimal_non_dust();
+ let total_input_value: Amount =
+ inputs.iter().map(|input| input.utxo.output.value).sum();
+ match total_input_value.checked_sub(required_value) {
+ Some(new_change_value) if new_change_value >= dust_limit => {
+ let new_change_output = TxOut {
+ value: new_change_value,
+ script_pubkey: change_output.script_pubkey.clone(),
+ };
+ return Some(FundingContribution {
+ estimated_fee,
+ inputs,
+ outputs: outputs.to_vec(),
+ change_output: Some(new_change_output),
+ ..contribution
+ });
+ },
+ _ => {},
+ }
+ }
+ }
+
+ let estimated_fee_no_change = estimate_transaction_fee(
+ &inputs,
+ &outputs,
+ None,
+ true,
+ contribution.is_splice,
+ contribution.feerate,
+ );
+ Some(FundingContribution {
+ estimated_fee: estimated_fee_no_change,
+ outputs: outputs.to_vec(),
+ inputs,
+ change_output: None,
+ ..contribution
+ })
+ };
+
+ let new_contribution_at_current_feerate =
+ adjust_for_inputs_and_outputs(self, inputs, outputs)?;
+ let mut new_contribution_at_target_feerate = new_contribution_at_current_feerate
+ .at_feerate(target_feerate, holder_balance, true)
+ .ok()?;
+ new_contribution_at_target_feerate.max_feerate = max_feerate;
+
+ Some(new_contribution_at_target_feerate)
+ }
+
pub(super) fn into_tx_parts(self) -> (Vec<FundingTxInput>, Vec<TxOut>) {
let FundingContribution { inputs, mut outputs, change_output, .. } = self;
@@ -842,32 +1023,6 @@ impl FundingContribution {
}
}
- /// Validates that the funding inputs are suitable for use in the interactive transaction
- /// protocol, checking prevtx sizes.
- pub fn validate(&self) -> Result<(), String> {
- for FundingTxInput { utxo, prevtx, .. } in self.inputs.iter() {
- use crate::util::ser::Writeable;
- const MESSAGE_TEMPLATE: msgs::TxAddInput = msgs::TxAddInput {
- channel_id: ChannelId([0; 32]),
- serial_id: 0,
- prevtx: None,
- prevtx_out: 0,
- sequence: 0,
- // Mutually exclusive with prevtx, which is accounted for below.
- shared_input_txid: None,
- };
- let message_len = MESSAGE_TEMPLATE.serialized_length() + prevtx.serialized_length();
- if message_len > LN_MAX_MSG_LEN {
- return Err(format!(
- "Funding input references a prevtx that is too large for tx_add_input: {}",
- utxo.outpoint
- ));
- }
- }
-
- Ok(())
- }
-
/// Computes the adjusted fee and change output value at the given target feerate, which may
/// differ from the feerate used during coin selection.
///
@@ -1112,17 +1267,511 @@ impl FundingContribution {
/// establishment protocol or when splicing.
pub type FundingTxInput = crate::util::wallet_utils::ConfirmedUtxo;
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct NoCoinSelectionSource;
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct AsyncCoinSelectionSource<W>(W);
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct SyncCoinSelectionSource<W>(W);
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct FundingBuilderInner<State> {
+ shared_input: Option<Input>,
+ min_rbf_feerate: Option<FeeRate>,
+ prior_contribution: Option<PriorContribution>,
+ value_added: Amount,
+ outputs: Vec<TxOut>,
+ feerate: FeeRate,
+ max_feerate: FeeRate,
+ state: State,
+}
+
+/// A builder for composing or amending a [`FundingContribution`].
+///
+/// The builder tracks a requested amount to add to the channel together with any explicit
+/// withdrawal outputs. Building without an attached wallet only succeeds when the request can be
+/// satisfied by reusing or amending a prior contribution, or by constructing a pure splice-out
+/// that pays fees from the channel balance.
+///
+/// Attach a wallet via [`FundingBuilder::with_coin_selection_source`] or
+/// [`FundingBuilder::with_coin_selection_source_sync`] when the request may need new wallet
+/// inputs.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct FundingBuilder(FundingBuilderInner<NoCoinSelectionSource>);
+
+/// A [`FundingBuilder`] with an attached asynchronous [`CoinSelectionSource`].
+///
+/// Created by [`FundingBuilder::with_coin_selection_source`]. The attached wallet is only used
+/// if the request cannot be satisfied by reusing a prior contribution or by building a pure
+/// splice-out directly.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct AsyncFundingBuilder<W>(FundingBuilderInner<AsyncCoinSelectionSource<W>>);
+
+/// A [`FundingBuilder`] with an attached synchronous [`CoinSelectionSourceSync`].
+///
+/// Created by [`FundingBuilder::with_coin_selection_source_sync`]. The attached wallet is only
+/// used if the request cannot be satisfied by reusing a prior contribution or by building a pure
+/// splice-out directly.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct SyncFundingBuilder<W>(FundingBuilderInner<SyncCoinSelectionSource<W>>);
+
+impl<State> FundingBuilderInner<State> {
+ fn request_matches_prior(&self, prior_contribution: &FundingContribution) -> bool {
+ self.value_added == prior_contribution.value_added()
+ && self.outputs == prior_contribution.outputs
+ }
+
+ fn build_from_prior_contribution(
+ &mut self, contribution: PriorContribution,
+ ) -> Result<FundingContribution, FundingContributionError> {
+ let PriorContribution { contribution, holder_balance } = contribution;
+
+ if self.request_matches_prior(&contribution) {
+ // Same request, but the feerate may have changed. Adjust the prior contribution
+ // to the new feerate if possible.
+ return contribution
+ .for_initiator_at_feerate(self.feerate, holder_balance)
+ .map(|mut adjusted| {
+ adjusted.max_feerate = self.max_feerate;
+ adjusted
+ })
+ .map_err(|_| FundingContributionError::MissingCoinSelectionSource);
+ }
+
+ let funding_inputs = if self.value_added != Amount::ZERO {
+ FundingInputs::CoinSelected { value_added: self.value_added }
+ } else {
+ FundingInputs::None
+ };
+ return contribution
+ .amend_without_coin_selection(
+ funding_inputs,
+ &self.outputs,
+ self.feerate,
+ self.max_feerate,
+ holder_balance,
+ )
+ .ok_or_else(|| FundingContributionError::MissingCoinSelectionSource);
+ }
+
+ /// Tries to build the current request without selecting any new wallet inputs.
+ ///
+ /// This first attempts to reuse or amend any prior contribution. If there is no prior
+ /// contribution, it also supports pure splice-out requests by building a contribution that pays
+ /// fees from the channel balance.
+ ///
+ /// Returns [`FundingContributionError::MissingCoinSelectionSource`] if the request is
+ /// otherwise valid but needs wallet inputs.
+ fn try_build_without_coin_selection(
+ &mut self,
+ ) -> Result<FundingContribution, FundingContributionError> {
+ if let Some(contribution) = self.prior_contribution.take() {
+ return self.build_from_prior_contribution(contribution);
+ }
+
+ if self.value_added == Amount::ZERO {
+ let estimated_fee = estimate_transaction_fee(
+ &[],
+ &self.outputs,
+ None,
+ true,
+ self.shared_input.is_some(),
+ self.feerate,
+ );
+ return Ok(FundingContribution {
+ estimated_fee,
+ inputs: vec![],
+ outputs: core::mem::take(&mut self.outputs),
+ change_output: None,
+ feerate: self.feerate,
+ max_feerate: self.max_feerate,
+ is_splice: self.shared_input.is_some(),
+ });
+ }
+
+ Err(FundingContributionError::MissingCoinSelectionSource)
+ }
+
+ fn prepare_coin_selection_request(
+ &self,
+ ) -> Result<(Vec<Input>, Vec<TxOut>), FundingContributionError> {
+ let dummy_pubkey = PublicKey::from_slice(&[2; 33]).unwrap();
+ let shared_output = bitcoin::TxOut {
+ value: self
+ .shared_input
+ .as_ref()
+ .map(|shared_input| shared_input.previous_utxo.value)
+ .unwrap_or(Amount::ZERO)
+ .checked_add(self.value_added)
+ .ok_or(FundingContributionError::InvalidSpliceValue)?,
+ script_pubkey: make_funding_redeemscript(&dummy_pubkey, &dummy_pubkey).to_p2wsh(),
+ };
+
+ let must_spend = self.shared_input.clone().map(|input| vec![input]).unwrap_or_default();
+ let must_pay_to = if self.outputs.is_empty() {
+ vec![shared_output]
+ } else {
+ self.outputs.iter().cloned().chain(core::iter::once(shared_output)).collect()
+ };
+
+ Ok((must_spend, must_pay_to))
+ }
+
+ fn validate_contribution_parameters(&self) -> Result<(), FundingContributionError> {
+ if self.feerate > self.max_feerate {
+ return Err(FundingContributionError::FeeRateExceedsMaximum {
+ feerate: self.feerate,
+ max_feerate: self.max_feerate,
+ });
+ }
+
+ if let Some(min_rbf_feerate) = self.min_rbf_feerate.as_ref() {
+ if self.feerate < *min_rbf_feerate {
+ return Err(FundingContributionError::FeeRateBelowRbfMinimum {
+ feerate: self.feerate,
+ min_rbf_feerate: *min_rbf_feerate,
+ });
+ }
+ }
+
+ if self.value_added == Amount::ZERO && self.outputs.is_empty() {
+ return Err(FundingContributionError::InvalidSpliceValue);
+ }
+
+ // 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 self.value_added > Amount::MAX_MONEY {
+ return Err(FundingContributionError::InvalidSpliceValue);
+ }
+
+ let mut value_removed = Amount::ZERO;
+ for output in self.outputs.iter() {
+ value_removed = match value_removed.checked_add(output.value) {
+ Some(sum) if sum <= Amount::MAX_MONEY => sum,
+ _ => return Err(FundingContributionError::InvalidSpliceValue),
+ };
+ }
+
+ Ok(())
+ }
+}
+
+impl FundingBuilder {
+ fn new(template: FundingTemplate, feerate: FeeRate, max_feerate: FeeRate) -> FundingBuilder {
+ let FundingTemplate { shared_input, min_rbf_feerate, prior_contribution } = template;
+ let (value_added, outputs) = match prior_contribution.as_ref() {
+ Some(prior) => {
+ let outputs = prior.contribution.outputs.clone();
+ (prior.contribution.value_added(), outputs)
+ },
+ None => (Amount::ZERO, Vec::new()),
+ };
+
+ FundingBuilder(FundingBuilderInner {
+ shared_input,
+ min_rbf_feerate,
+ prior_contribution,
+ value_added,
+ outputs,
+ feerate,
+ max_feerate,
+ state: NoCoinSelectionSource,
+ })
+ }
+
+ /// Attaches an asynchronous [`CoinSelectionSource`] for later use.
+ ///
+ /// The wallet is only consulted if [`AsyncFundingBuilder::build`] cannot satisfy the request by
+ /// reusing a prior contribution or by constructing a pure splice-out directly.
+ pub fn with_coin_selection_source<W: CoinSelectionSource + MaybeSend>(
+ self, wallet: W,
+ ) -> AsyncFundingBuilder<W> {
+ AsyncFundingBuilder(self.0.with_state(AsyncCoinSelectionSource(wallet)))
+ }
+
+ /// Attaches a synchronous [`CoinSelectionSourceSync`] for later use.
+ ///
+ /// The wallet is only consulted if [`SyncFundingBuilder::build`] cannot satisfy the request by
+ /// reusing a prior contribution or by constructing a pure splice-out directly.
+ pub fn with_coin_selection_source_sync<W: CoinSelectionSourceSync>(
+ self, wallet: W,
+ ) -> SyncFundingBuilder<W> {
+ SyncFundingBuilder(self.0.with_state(SyncCoinSelectionSource(wallet)))
+ }
+
+ /// Adds a withdrawal output to the request.
+ ///
+ /// `output` is appended to the current set of explicit outputs. If the builder was seeded from
+ /// a prior contribution, this adds an additional withdrawal on top of the prior outputs. This
+ /// does not affect any change output derived when the contribution is built.
+ pub fn add_output(self, output: TxOut) -> Self {
+ FundingBuilder(self.0.add_output_inner(output))
+ }
+
+ /// Removes all explicit withdrawal outputs whose script pubkey matches `script_pubkey`.
+ ///
+ /// This only affects outputs returned by [`FundingContribution::outputs`]; it never removes the
+ /// change output returned by [`FundingContribution::change_output`].
+ pub fn remove_outputs(self, script_pubkey: &ScriptBuf) -> Self {
+ FundingBuilder(self.0.remove_outputs_inner(script_pubkey))
+ }
+
+ /// Builds a [`FundingContribution`] without coin selection.
+ ///
+ /// This succeeds when the request can be satisfied by reusing or amending a prior
+ /// contribution, or by building a splice-out contribution that pays fees from the channel
+ /// balance.
+ ///
+ /// Returns [`FundingContributionError::MissingCoinSelectionSource`] if additional wallet
+ /// inputs are needed.
+ pub fn build(mut self) -> Result<FundingContribution, FundingContributionError> {
+ self.0.build_without_coin_selection()
+ }
+}
+
+impl<State> FundingBuilderInner<State> {
+ fn with_state<NewState>(self, state: NewState) -> FundingBuilderInner<NewState> {
+ FundingBuilderInner {
+ shared_input: self.shared_input,
+ min_rbf_feerate: self.min_rbf_feerate,
+ prior_contribution: self.prior_contribution,
+ value_added: self.value_added,
+ outputs: self.outputs,
+ feerate: self.feerate,
+ max_feerate: self.max_feerate,
+ state,
+ }
+ }
+
+ fn add_value_inner(mut self, value: Amount) -> Self {
+ self.value_added =
+ Amount::from_sat(self.value_added.to_sat().saturating_add(value.to_sat()));
+ self
+ }
+
+ fn remove_value_inner(mut self, value: Amount) -> Self {
+ self.value_added =
+ Amount::from_sat(self.value_added.to_sat().saturating_sub(value.to_sat()));
+ self
+ }
+
+ fn add_output_inner(mut self, output: TxOut) -> Self {
+ self.outputs.push(output);
+ self
+ }
+
+ fn remove_outputs_inner(mut self, script_pubkey: &ScriptBuf) -> Self {
+ self.outputs.retain(|output| output.script_pubkey != *script_pubkey);
+ self
+ }
+
+ /// Validates the current request and then tries to build it without selecting new wallet
+ /// inputs.
+ ///
+ /// Returns [`FundingContributionError::MissingCoinSelectionSource`] if the request is valid but
+ /// cannot be satisfied without wallet inputs.
+ fn build_without_coin_selection(
+ &mut self,
+ ) -> Result<FundingContribution, FundingContributionError> {
+ self.validate_contribution_parameters()?;
+ self.try_build_without_coin_selection()
+ }
+}
+
+impl<W> AsyncFundingBuilder<W> {
+ /// Adds a withdrawal output to the request.
+ ///
+ /// `output` is appended to the current set of explicit outputs. If the builder was seeded from
+ /// a prior contribution, this adds an additional withdrawal on top of the prior outputs. This
+ /// does not affect any change output derived when the contribution is built.
+ pub fn add_output(self, output: TxOut) -> Self {
+ AsyncFundingBuilder(self.0.add_output_inner(output))
+ }
+
+ /// Removes all explicit withdrawal outputs whose script pubkey matches `script_pubkey`.
+ ///
+ /// This only affects outputs returned by [`FundingContribution::outputs`]; it never removes the
+ /// change output returned by [`FundingContribution::change_output`].
+ pub fn remove_outputs(self, script_pubkey: &ScriptBuf) -> Self {
+ AsyncFundingBuilder(self.0.remove_outputs_inner(script_pubkey))
+ }
+
+ /// Increases the requested amount to add to the channel.
+ ///
+ /// `value` is added on top of the builder's current request. If the builder was seeded from a
+ /// prior contribution, this increases that prior contribution's current amount added to the
+ /// channel. If the updated request cannot be satisfied in-place, [`AsyncFundingBuilder::build`]
+ /// may re-run coin selection and return a contribution with a different input set.
+ pub fn add_value(self, value: Amount) -> Self {
+ AsyncFundingBuilder(self.0.add_value_inner(value))
+ }
+
+ /// Decreases the requested amount to add to the channel.
+ ///
+ /// `value` is subtracted from the builder's current request, saturating at zero. If the builder
+ /// was seeded from a prior contribution, this decreases that prior contribution's current
+ /// amount added to the channel. If the updated request cannot be satisfied in-place,
+ /// [`AsyncFundingBuilder::build`] may re-run coin selection and return a contribution with a
+ /// different input set.
+ pub fn remove_value(self, value: Amount) -> Self {
+ AsyncFundingBuilder(self.0.remove_value_inner(value))
+ }
+}
+
+impl<W: CoinSelectionSource + MaybeSend> AsyncFundingBuilder<W> {
+ /// Builds a [`FundingContribution`], using the attached asynchronous wallet only when needed.
+ ///
+ /// If the request can be satisfied by reusing or amending a prior contribution, or by building
+ /// a pure splice-out directly, the attached wallet is ignored.
+ pub async fn build(self) -> Result<FundingContribution, FundingContributionError> {
+ let mut inner = self.0;
+ match inner.build_without_coin_selection() {
+ Err(FundingContributionError::MissingCoinSelectionSource) => {},
+ other => return other,
+ }
+
+ let (must_spend, must_pay_to) = inner.prepare_coin_selection_request()?;
+ let AsyncCoinSelectionSource(wallet) = inner.state;
+ let coin_selection = wallet
+ .select_confirmed_utxos(
+ None,
+ must_spend,
+ &must_pay_to,
+ inner.feerate.to_sat_per_kwu() as u32,
+ u64::MAX,
+ )
+ .await
+ .map_err(|_| FundingContributionError::CoinSelectionFailed)?;
+
+ let CoinSelection { confirmed_utxos: inputs, change_output } = coin_selection;
+ validate_inputs(&inputs)?;
+
+ let outputs = inner.outputs;
+ let is_splice = inner.shared_input.is_some();
+ let estimated_fee = estimate_transaction_fee(
+ &inputs,
+ &outputs,
+ change_output.as_ref(),
+ true,
+ is_splice,
+ inner.feerate,
+ );
+
+ return Ok(FundingContribution {
+ estimated_fee,
+ inputs,
+ outputs,
+ change_output,
+ feerate: inner.feerate,
+ max_feerate: inner.max_feerate,
+ is_splice,
+ });
+ }
+}
+
+impl<W> SyncFundingBuilder<W> {
+ /// Adds a withdrawal output to the request.
+ ///
+ /// `output` is appended to the current set of explicit outputs. If the builder was seeded from
+ /// a prior contribution, this adds an additional withdrawal on top of the prior outputs. This
+ /// does not affect any change output derived when the contribution is built.
+ pub fn add_output(self, output: TxOut) -> Self {
+ SyncFundingBuilder(self.0.add_output_inner(output))
+ }
+
+ /// Removes all explicit withdrawal outputs whose script pubkey matches `script_pubkey`.
+ ///
+ /// This only affects outputs returned by [`FundingContribution::outputs`]; it never removes the
+ /// change output returned by [`FundingContribution::change_output`].
+ pub fn remove_outputs(self, script_pubkey: &ScriptBuf) -> Self {
+ SyncFundingBuilder(self.0.remove_outputs_inner(script_pubkey))
+ }
+
+ /// Increases the requested amount to add to the channel.
+ ///
+ /// `value` is added on top of the builder's current request. If the builder was seeded from a
+ /// prior contribution, this increases that prior contribution's current amount added to the
+ /// channel. If the updated request cannot be satisfied in-place, [`SyncFundingBuilder::build`]
+ /// may re-run coin selection and return a contribution with a different input set.
+ pub fn add_value(self, value: Amount) -> Self {
+ SyncFundingBuilder(self.0.add_value_inner(value))
+ }
+
+ /// Decreases the requested amount to add to the channel.
+ ///
+ /// `value` is subtracted from the builder's current request, saturating at zero. If the builder
+ /// was seeded from a prior contribution, this decreases that prior contribution's current
+ /// amount added to the channel. If the updated request cannot be satisfied in-place,
+ /// [`SyncFundingBuilder::build`] may re-run coin selection and return a contribution with a
+ /// different input set.
+ pub fn remove_value(self, value: Amount) -> Self {
+ SyncFundingBuilder(self.0.remove_value_inner(value))
+ }
+}
+
+impl<W: CoinSelectionSourceSync> SyncFundingBuilder<W> {
+ /// Builds a [`FundingContribution`], using the attached synchronous wallet only when needed.
+ ///
+ /// If the request can be satisfied by reusing or amending a prior contribution, or by building
+ /// a pure splice-out directly, the attached wallet is ignored.
+ pub fn build(self) -> Result<FundingContribution, FundingContributionError> {
+ let mut inner = self.0;
+ match inner.build_without_coin_selection() {
+ Err(FundingContributionError::MissingCoinSelectionSource) => {},
+ other => return other,
+ }
+
+ let (must_spend, must_pay_to) = inner.prepare_coin_selection_request()?;
+ let SyncCoinSelectionSource(wallet) = inner.state;
+ let coin_selection = wallet
+ .select_confirmed_utxos(
+ None,
+ must_spend,
+ &must_pay_to,
+ inner.feerate.to_sat_per_kwu() as u32,
+ u64::MAX,
+ )
+ .map_err(|_| FundingContributionError::CoinSelectionFailed)?;
+
+ let CoinSelection { confirmed_utxos: inputs, change_output } = coin_selection;
+ validate_inputs(&inputs)?;
+
+ let outputs = inner.outputs;
+ let is_splice = inner.shared_input.is_some();
+ let estimated_fee = estimate_transaction_fee(
+ &inputs,
+ &outputs,
+ change_output.as_ref(),
+ true,
+ is_splice,
+ inner.feerate,
+ );
+
+ return Ok(FundingContribution {
+ estimated_fee,
+ inputs,
+ outputs,
+ change_output,
+ feerate: inner.feerate,
+ max_feerate: inner.max_feerate,
+ is_splice,
+ });
+ }
+}
+
#[cfg(test)]
mod tests {
use super::{
- estimate_transaction_fee, FeeRateAdjustmentError, FundingContribution,
+ estimate_transaction_fee, FeeRateAdjustmentError, FundingBuilder, FundingContribution,
FundingContributionError, FundingTemplate, FundingTxInput, PriorContribution,
};
use crate::chain::ClaimId;
use crate::util::wallet_utils::{CoinSelection, CoinSelectionSourceSync, Input};
use bitcoin::hashes::Hash;
use bitcoin::transaction::{Transaction, TxOut, Version};
- use bitcoin::{Amount, FeeRate, Psbt, ScriptBuf, SignedAmount, WPubkeyHash};
+ use bitcoin::{Amount, FeeRate, Psbt, ScriptBuf, SignedAmount, WPubkeyHash, WScriptHash};
#[test]
#[rustfmt::skip]
@@ -1234,6 +1883,218 @@ mod tests {
}
}
+ struct MustPayToWallet {
+ utxo: FundingTxInput,
+ change_output: Option<TxOut>,
+ expected_must_pay_to_values: Vec<Amount>,
+ }
+
+ impl CoinSelectionSourceSync for MustPayToWallet {
+ 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, ()> {
+ assert_eq!(
+ must_pay_to.iter().map(|output| output.value).collect::<Vec<_>>(),
+ self.expected_must_pay_to_values,
+ );
+ 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")
+ }
+ }
+
+ #[test]
+ fn test_funding_builder_builds_splice_out_without_wallet() {
+ let feerate = FeeRate::from_sat_per_kwu(2000);
+ let output = funding_output_sats(25_000);
+
+ let contribution =
+ FundingBuilder::new(FundingTemplate::new(None, None, None), feerate, FeeRate::MAX)
+ .add_output(output.clone())
+ .build()
+ .unwrap();
+
+ let expected_fee = estimate_transaction_fee(
+ &[],
+ std::slice::from_ref(&output),
+ None,
+ true,
+ false,
+ feerate,
+ );
+ assert!(contribution.inputs.is_empty());
+ assert_eq!(contribution.outputs, vec![output.clone()]);
+ assert!(contribution.change_output.is_none());
+ assert_eq!(contribution.estimated_fee, expected_fee);
+ assert_eq!(
+ contribution.net_value(),
+ -output.value.to_signed().unwrap() - expected_fee.to_signed().unwrap(),
+ );
+ }
+
+ #[test]
+ fn test_funding_builder_requires_wallet_for_splice_in() {
+ let feerate = FeeRate::from_sat_per_kwu(2000);
+ let builder =
+ FundingBuilder::new(FundingTemplate::new(None, None, None), feerate, FeeRate::MAX);
+ let builder = FundingBuilder(builder.0.add_value_inner(Amount::from_sat(25_000)));
+
+ assert!(matches!(
+ builder.build(),
+ Err(FundingContributionError::MissingCoinSelectionSource),
+ ));
+ }
+
+ #[test]
+ fn test_funding_builder_amends_prior_by_dropping_subdust_change() {
+ let feerate = FeeRate::from_sat_per_kwu(2000);
+ let inputs = vec![funding_input_sats(100_000)];
+ let change = funding_output_sats(500);
+ let dust_limit = change.script_pubkey.minimal_non_dust();
+ assert!(change.value >= dust_limit);
+
+ let estimated_fee_with_change =
+ estimate_transaction_fee(&inputs, &[], Some(&change), true, true, feerate);
+ let estimated_fee_no_change =
+ estimate_transaction_fee(&inputs, &[], None, true, true, feerate);
+ let prior = FundingContribution {
+ estimated_fee: estimated_fee_with_change,
+ inputs: inputs.clone(),
+ outputs: vec![],
+ change_output: Some(change.clone()),
+ feerate,
+ max_feerate: FeeRate::MAX,
+ is_splice: true,
+ };
+
+ let delta = Amount::from_sat(change.value.to_sat() - dust_limit.to_sat() + 1);
+ let target_value_added = prior.value_added().checked_add(delta).unwrap();
+ let total_input_value: Amount = inputs.iter().map(|input| input.utxo.output.value).sum();
+ let remaining_change = total_input_value
+ .checked_sub(target_value_added.checked_add(estimated_fee_with_change).unwrap())
+ .unwrap();
+ assert_eq!(remaining_change.to_sat(), dust_limit.to_sat() - 1);
+ assert!(
+ total_input_value >= target_value_added.checked_add(estimated_fee_no_change).unwrap()
+ );
+
+ let builder =
+ FundingTemplate::new(None, None, Some(PriorContribution::new(prior, Amount::MAX)))
+ .with_prior_contribution(feerate, FeeRate::MAX);
+ let contribution = FundingBuilder(builder.0.add_value_inner(delta)).build().unwrap();
+
+ assert!(contribution.change_output.is_none());
+ assert_eq!(contribution.inputs, inputs);
+ assert!(contribution.outputs.is_empty());
+ assert_eq!(contribution.estimated_fee, estimated_fee_no_change);
+ assert_eq!(
+ contribution.value_added(),
+ total_input_value.checked_sub(estimated_fee_no_change).unwrap()
+ );
+ assert!(contribution.value_added() > target_value_added);
+ }
+
+ #[test]
+ fn test_funding_builder_remove_outputs_removes_all_matching_scripts() {
+ let feerate = FeeRate::from_sat_per_kwu(2000);
+ let removed_script = ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros());
+ let kept_script = ScriptBuf::new_p2wsh(&WScriptHash::all_zeros());
+ let removed_output_1 =
+ TxOut { value: Amount::from_sat(10_000), script_pubkey: removed_script.clone() };
+ let removed_output_2 =
+ TxOut { value: Amount::from_sat(12_000), script_pubkey: removed_script.clone() };
+ let kept_output = TxOut { value: Amount::from_sat(15_000), script_pubkey: kept_script };
+
+ let contribution =
+ FundingBuilder::new(FundingTemplate::new(None, None, None), feerate, FeeRate::MAX)
+ .add_output(removed_output_1)
+ .add_output(kept_output.clone())
+ .add_output(removed_output_2)
+ .remove_outputs(&removed_script)
+ .build()
+ .unwrap();
+
+ assert_eq!(contribution.outputs, vec![kept_output]);
+ }
+
+ #[test]
+ fn test_funding_builder_add_and_remove_value_update_request() {
+ let feerate = FeeRate::from_sat_per_kwu(2000);
+ let builder =
+ FundingBuilder::new(FundingTemplate::new(None, None, None), feerate, FeeRate::MAX)
+ .with_coin_selection_source_sync(UnreachableWallet)
+ .add_value(Amount::from_sat(20_000))
+ .add_value(Amount::from_sat(5_000))
+ .remove_value(Amount::from_sat(10_000));
+
+ let (_, must_pay_to) = builder.0.prepare_coin_selection_request().unwrap();
+ assert_eq!(must_pay_to.len(), 1);
+ assert_eq!(must_pay_to[0].value, Amount::from_sat(15_000));
+ }
+
+ #[test]
+ fn test_coin_selection_request_funds_outputs_from_inputs() {
+ let feerate = FeeRate::from_sat_per_kwu(2000);
+ let value_added = Amount::from_sat(15_000);
+ let output = funding_output_sats(8_000);
+ let input = funding_input_sats(50_000);
+ let change_template = funding_output_sats(1_000);
+ let estimated_fee = estimate_transaction_fee(
+ std::slice::from_ref(&input),
+ std::slice::from_ref(&output),
+ Some(&change_template),
+ true,
+ false,
+ feerate,
+ );
+ let change_value = input.utxo.output.value - value_added - output.value - estimated_fee;
+ let wallet = MustPayToWallet {
+ utxo: input,
+ change_output: Some(TxOut {
+ value: change_value,
+ script_pubkey: change_template.script_pubkey,
+ }),
+ expected_must_pay_to_values: vec![output.value, value_added],
+ };
+
+ let contribution =
+ FundingBuilder::new(FundingTemplate::new(None, None, None), feerate, FeeRate::MAX)
+ .with_coin_selection_source_sync(wallet)
+ .add_value(value_added)
+ .add_output(output.clone())
+ .build()
+ .unwrap();
+
+ assert_eq!(contribution.value_added(), value_added);
+ assert_eq!(contribution.outputs, vec![output]);
+ assert_eq!(contribution.change_output.as_ref().unwrap().value, change_value);
+ }
+
+ #[test]
+ fn test_funding_builder_remove_value_saturates_at_zero() {
+ let feerate = FeeRate::from_sat_per_kwu(2000);
+ let output = funding_output_sats(8_000);
+ let contribution =
+ FundingBuilder::new(FundingTemplate::new(None, None, None), feerate, FeeRate::MAX)
+ .with_coin_selection_source_sync(UnreachableWallet)
+ .add_value(Amount::from_sat(10_000))
+ .remove_value(Amount::from_sat(15_000))
+ .add_output(output.clone())
+ .build()
+ .unwrap();
+
+ assert!(contribution.inputs.is_empty());
+ assert_eq!(contribution.outputs, vec![output]);
+ assert!(contribution.change_output.is_none());
+ assert_eq!(contribution.value_added(), Amount::ZERO);
+ }
+
#[test]
fn test_build_funding_contribution_validates_max_money() {
let over_max = Amount::MAX_MONEY + Amount::from_sat(1);
@@ -1334,6 +2195,33 @@ mod tests {
}
}
+ #[test]
+ fn test_build_funding_contribution_rejects_oversized_prevtx() {
+ use crate::util::ser::Writeable;
+
+ let feerate = FeeRate::from_sat_per_kwu(2000);
+ let prevtx = Transaction {
+ input: vec![],
+ output: vec![funding_output_sats(50_000); 2_200],
+ version: Version::TWO,
+ lock_time: bitcoin::absolute::LockTime::ZERO,
+ };
+ assert!(prevtx.serialized_length() > crate::ln::LN_MAX_MSG_LEN);
+
+ let wallet = SingleUtxoWallet {
+ utxo: FundingTxInput::new_p2wpkh(prevtx, 0).unwrap(),
+ change_output: None,
+ };
+ assert!(matches!(
+ FundingTemplate::new(None, None, None)
+ .with_prior_contribution(feerate, feerate)
+ .with_coin_selection_source_sync(wallet)
+ .add_value(Amount::from_sat(10_000))
+ .build(),
+ Err(FundingContributionError::PrevTxTooLarge),
+ ));
+ }
+
#[test]
fn test_for_acceptor_at_feerate_higher_change_adjusted() {
// Splice-in: higher target feerate reduces the change output.
Why this scored 32/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.