From 92c7e610d4ce0140751e12319cbe7e72c3c38d9c Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Thu, 23 Apr 2026 10:07:44 -0700 Subject: [PATCH 1/2] Support manually selecting inputs consuming their entire value This commit introduces an alternative way of splicing in funds without coin selection by requiring the full UTXO to be provided. Each UTXO's entire value (minus fees) is allocated towards the channel, which provides unified balance wallets a more intuitive API when splicing funds into the channel, as they don't particularly care about maintaining a portion of their balance onchain. To simplify the implementation, we require that contributions are not allowed to mix coin-selected inputs with manually-selected ones. Users will need to start a fresh contribution if they want to change the funding input mode. --- lightning/src/ln/funding.rs | 858 +++++++++++++++++++++++++---- lightning/src/ln/splicing_tests.rs | 65 ++- 2 files changed, 799 insertions(+), 124 deletions(-) diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index 20366fe772a..5dd6fe2acea 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -24,7 +24,7 @@ use crate::ln::LN_MAX_MSG_LEN; use crate::prelude::*; use crate::util::native_async::MaybeSend; use crate::util::wallet_utils::{ - CoinSelection, CoinSelectionSource, CoinSelectionSourceSync, Input, + CoinSelection, CoinSelectionSource, CoinSelectionSourceSync, ConfirmedUtxo, Input, }; /// Error returned when a [`FundingContribution`] cannot be adjusted to a target feerate. @@ -147,6 +147,8 @@ pub enum FundingContributionError { /// the builder fall back to fresh coin selection, which may replace the prior input set instead /// of preserving it. MissingCoinSelectionSource, + /// The request cannot be satisfied using the manually selected inputs. + ManuallySelectedInputsInsufficient, /// This template cannot build an RBF contribution. NotRbfScenario, } @@ -172,6 +174,9 @@ impl core::fmt::Display for FundingContributionError { FundingContributionError::MissingCoinSelectionSource => { write!(f, "Coin selection source required to build this contribution") }, + FundingContributionError::ManuallySelectedInputsInsufficient => { + write!(f, "The request cannot be satisfied using the manually selected inputs") + }, FundingContributionError::NotRbfScenario => { write!(f, "This template cannot build an RBF contribution") }, @@ -336,7 +341,9 @@ impl FundingTemplate { /// 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. + /// the prior contribution used. This is not supported when the prior contribution used manually + /// selected inputs; use [`FundingTemplate::splice_in_inputs`] or + /// [`FundingTemplate::without_prior_contribution`] in that case. pub async fn splice_in( self, value_added: Amount, min_feerate: FeeRate, max_feerate: FeeRate, wallet: W, ) -> Result { @@ -350,7 +357,8 @@ impl FundingTemplate { /// Creates a [`FundingContribution`] for adding funds to a channel. /// /// This is the synchronous variant of [`FundingTemplate::splice_in`]; `value_added`, - /// `min_feerate`, `max_feerate`, and `wallet` have the same meaning. + /// `min_feerate`, `max_feerate`, and `wallet` have the same meaning, including the restriction + /// on prior contributions with manually selected inputs. pub fn splice_in_sync( self, value_added: Amount, min_feerate: FeeRate, max_feerate: FeeRate, wallet: W, ) -> Result { @@ -360,6 +368,29 @@ impl FundingTemplate { .build() } + /// Creates a [`FundingContribution`] for adding funds to a channel using manually selected + /// inputs. + /// + /// This is a convenience wrapper around [`FundingTemplate::with_prior_contribution`] with no + /// wallet attached. Each input is fully consumed with no change output, so the amount added to + /// the channel is derived from the total input value minus the estimated fee. + /// + /// When a prior contribution with manually selected inputs is present, `inputs` are appended to + /// the prior [`FundingContribution::inputs`] instead of replacing them. Use + /// [`FundingTemplate::without_prior_contribution`] if you want to replace the prior request + /// instead. If the template carries a coin-selected prior contribution, manual inputs are + /// incompatible and this method returns [`FundingContributionError::InvalidSpliceValue`]. + /// + /// `inputs` are the additional manually selected inputs to fully consume. `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`. + pub fn splice_in_inputs( + self, inputs: Vec, min_feerate: FeeRate, max_feerate: FeeRate, + ) -> Result { + self.with_prior_contribution(min_feerate, max_feerate).add_inputs(inputs).build() + } + /// Creates a [`FundingContribution`] for removing funds from a channel. /// /// This is a convenience wrapper around [`FundingTemplate::with_prior_contribution`] with no @@ -528,7 +559,7 @@ fn validate_inputs(inputs: &[FundingTxInput]) -> Result<(), FundingContributionE } /// Describes how an amended contribution should source its wallet-backed inputs. -enum FundingInputs { +enum FundingInputs<'a> { 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 @@ -536,16 +567,35 @@ enum FundingInputs { CoinSelected { value_added: Amount, }, + /// Replaces the contribution's inputs with the provided set and fully consumes them without a + /// change output. The amount added to the channel is recomputed from the input total minus fees, + /// while explicit withdrawal outputs still reduce the splice's net value. + ManuallySelected { + inputs: &'a [FundingTxInput], + }, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +enum FundingInputMode { + CoinSelected, + Manual, } +impl_writeable_tlv_based_enum!(FundingInputMode, + (1, CoinSelected) => {}, + (3, Manual) => {} +); + /// The components of a funding transaction contributed by one party. #[derive(Debug, Clone, PartialEq, Eq)] pub struct FundingContribution { /// The estimate fees responsible to be paid for the contribution. estimated_fee: Amount, - /// The inputs included in the funding transaction to meet the contributed amount plus fees. Any - /// excess amount will be sent to a change output. + /// The inputs included in the funding transaction. + /// + /// For coin-selected contributions, excess value is returned via [`Self::change_output`]. For + /// manually selected inputs, the full input value is consumed and no change output is created. inputs: Vec, /// The outputs to include in the funding transaction. @@ -565,6 +615,12 @@ pub struct FundingContribution { /// Whether the contribution is for funding a splice. is_splice: bool, + + /// Whether this contribution currently uses coin-selected or manual-input semantics. + /// + /// This is `None` when the contribution has no inputs. Builders resuming from a prior + /// contribution use the next non-empty input source to establish the mode again. + input_mode: Option, } impl_writeable_tlv_based!(FundingContribution, { @@ -575,6 +631,7 @@ impl_writeable_tlv_based!(FundingContribution, { (9, feerate, required), (11, max_feerate, required), (13, is_splice, required), + (15, input_mode, option), }); impl FundingContribution { @@ -594,11 +651,13 @@ impl FundingContribution { self.outputs.iter().chain(self.change_output.iter()) } - /// The value that will be added to the channel after fees. See [`Self::net_value`] for the net - /// value contribution to the channel. + /// The positive value added to the channel after explicit outputs and fees. + /// + /// This saturates at zero for net-negative contributions. See [`Self::net_value`] for the full + /// signed contribution to the channel. pub fn value_added(&self) -> Amount { let total_input_value = self.inputs.iter().map(|i| i.utxo.output.value).sum::(); - let total_output_value = self.outputs.iter().map(|output| output.value).sum::(); + let total_output_value = self.outputs.iter().map(|output| output.value).sum(); total_input_value .checked_sub(total_output_value) .and_then(|v| v.checked_sub(self.estimated_fee)) @@ -610,6 +669,11 @@ impl FundingContribution { .unwrap_or(Amount::ZERO) } + /// Returns the inputs included in this contribution. + pub fn inputs(&self) -> &[ConfirmedUtxo] { + &self.inputs + } + /// Returns the outputs (e.g., withdrawal destinations) included in this contribution. /// /// This does not include the change output; see [`FundingContribution::change_output`]. @@ -638,84 +702,90 @@ impl FundingContribution { /// 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, + self, funding_inputs: FundingInputs<'_>, outputs: &[TxOut], target_feerate: FeeRate, max_feerate: FeeRate, holder_balance: Amount, ) -> Option { // 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 { - 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; - } + let adjust_for_inputs_and_outputs = |contribution: Self, + inputs: FundingInputs<'_>, + outputs: &[TxOut]| + -> Option { + let (target_value_added, inputs, input_mode) = match inputs { + FundingInputs::None => (None, Vec::new(), None), + FundingInputs::CoinSelected { value_added } => { + (Some(value_added), contribution.inputs, Some(FundingInputMode::CoinSelected)) + }, + FundingInputs::ManuallySelected { inputs } => { + (None, inputs.to_vec(), Some(FundingInputMode::Manual)) + }, + }; - // 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 - }); - }, - _ => {}, - } - } - } + 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; + } - let estimated_fee_no_change = estimate_transaction_fee( + // 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, - None, + contribution.change_output.as_ref(), true, contribution.is_splice, contribution.feerate, ); - Some(FundingContribution { - estimated_fee: estimated_fee_no_change, - outputs: outputs.to_vec(), - inputs, - change_output: None, - ..contribution - }) - }; + 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), + input_mode, + ..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, + input_mode, + ..contribution + }) + }; let new_contribution_at_current_feerate = - adjust_for_inputs_and_outputs(self, inputs, outputs)?; + adjust_for_inputs_and_outputs(self, funding_inputs, outputs)?; let mut new_contribution_at_target_feerate = new_contribution_at_current_feerate .at_feerate(target_feerate, holder_balance, true) .ok()?; @@ -812,7 +882,9 @@ impl FundingContribution { target_feerate, ); - if !self.inputs.is_empty() { + if !self.inputs.is_empty() && self.input_mode == Some(FundingInputMode::CoinSelected) { + // Any withdrawal outputs and fees always come from the coin-selected inputs, as we want + // to guarantee the net contribution adds the desired value. let fee_buffer = self .estimated_fee .checked_add( @@ -858,16 +930,22 @@ impl FundingContribution { }) } } else { - // Without coin-selected inputs, both the withdrawals and the fee come from the channel - // balance. - let value_removed: Amount = self.outputs.iter().map(|o| o.value).sum(); - let total_cost = target_fee - .checked_add(value_removed) - .ok_or(FeeRateAdjustmentError::FeeBufferOverflow)?; - if total_cost > holder_balance { + // Manually selected inputs may either add value to the channel or offset some of the + // withdrawal outputs. Any remaining fee cost must come from the channel balance. + let net_value_without_fee = self.net_value_without_fee(); + let fee_buffer = if net_value_without_fee.is_negative() { + holder_balance + .checked_sub(net_value_without_fee.unsigned_abs()) + .unwrap_or(Amount::ZERO) + } else { + holder_balance + .checked_add(net_value_without_fee.unsigned_abs()) + .ok_or(FeeRateAdjustmentError::FeeBufferOverflow)? + }; + if fee_buffer < target_fee { return Err(FeeRateAdjustmentError::FeeBufferInsufficient { - source: "channel balance - withdrawal outputs", - available: holder_balance.checked_sub(value_removed).unwrap_or(Amount::ZERO), + source: "channel balance", + available: fee_buffer, required: target_fee, }); } @@ -1015,6 +1093,7 @@ struct FundingBuilderInner { min_rbf_feerate: Option, prior_contribution: Option, value_added: Amount, + manually_selected_inputs: Vec, outputs: Vec, feerate: FeeRate, max_feerate: FeeRate, @@ -1023,43 +1102,69 @@ struct FundingBuilderInner { /// 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. +/// The builder tracks either a requested amount to add to the channel or a fixed set of manually +/// selected inputs, 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, by using only manually selected inputs, or by constructing a 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. +/// inputs. Manually selected inputs are not supplemented with coin selection. #[derive(Debug, Clone, PartialEq, Eq)] pub struct FundingBuilder(FundingBuilderInner); /// 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. +/// if the request cannot be satisfied by reusing a prior contribution, by using only manually +/// selected inputs, or by building a pure splice-out directly. #[derive(Debug, Clone, PartialEq, Eq)] pub struct AsyncFundingBuilder(FundingBuilderInner>); /// 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. +/// used if the request cannot be satisfied by reusing a prior contribution, by using only +/// manually selected inputs, or by building a pure splice-out directly. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SyncFundingBuilder(FundingBuilderInner>); impl FundingBuilderInner { + fn request_input_mode(&self) -> Option { + if !self.manually_selected_inputs.is_empty() { + Some(FundingInputMode::Manual) + } else if self.value_added != Amount::ZERO { + Some(FundingInputMode::CoinSelected) + } else { + None + } + } + fn request_matches_prior(&self, prior_contribution: &FundingContribution) -> bool { - self.value_added == prior_contribution.value_added() - && self.outputs == prior_contribution.outputs + let request_matches_prior_inputs = + match (self.request_input_mode(), prior_contribution.input_mode) { + (Some(FundingInputMode::Manual), Some(FundingInputMode::Manual)) => { + let request_inputs = + self.manually_selected_inputs.iter().map(|input| input.utxo.outpoint); + let prior_inputs = + prior_contribution.inputs.iter().map(|input| input.utxo.outpoint); + request_inputs.eq(prior_inputs) + }, + (Some(FundingInputMode::CoinSelected), Some(FundingInputMode::CoinSelected)) => { + self.value_added == prior_contribution.value_added() + }, + (None, None) => true, + _ => false, + }; + request_matches_prior_inputs && self.outputs == prior_contribution.outputs } fn build_from_prior_contribution( &mut self, contribution: PriorContribution, ) -> Result { let PriorContribution { contribution, holder_balance } = contribution; + let input_mode = self.request_input_mode(); if self.request_matches_prior(&contribution) { // Same request, but the feerate may have changed. Adjust the prior contribution @@ -1070,13 +1175,23 @@ impl FundingBuilderInner { adjusted.max_feerate = self.max_feerate; adjusted }) - .map_err(|_| FundingContributionError::MissingCoinSelectionSource); + .map_err(|_| { + if input_mode == Some(FundingInputMode::Manual) { + FundingContributionError::ManuallySelectedInputsInsufficient + } else { + FundingContributionError::MissingCoinSelectionSource + } + }); } - let funding_inputs = if self.value_added != Amount::ZERO { - FundingInputs::CoinSelected { value_added: self.value_added } - } else { - FundingInputs::None + let funding_inputs = match input_mode { + Some(FundingInputMode::Manual) => { + FundingInputs::ManuallySelected { inputs: &self.manually_selected_inputs } + }, + Some(FundingInputMode::CoinSelected) => { + FundingInputs::CoinSelected { value_added: self.value_added } + }, + None => FundingInputs::None, }; return contribution .amend_without_coin_selection( @@ -1086,17 +1201,25 @@ impl FundingBuilderInner { self.max_feerate, holder_balance, ) - .ok_or_else(|| FundingContributionError::MissingCoinSelectionSource); + .ok_or_else(|| { + if input_mode == Some(FundingInputMode::Manual) { + FundingContributionError::ManuallySelectedInputsInsufficient + } 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. + /// contribution, it also supports manually selected inputs and pure splice-out requests by + /// building a contribution without coin selection. /// /// Returns [`FundingContributionError::MissingCoinSelectionSource`] if the request is - /// otherwise valid but needs wallet inputs. + /// otherwise valid but needs wallet inputs, or + /// [`FundingContributionError::ManuallySelectedInputsInsufficient`] if the manually selected + /// inputs cannot satisfy the request. fn try_build_without_coin_selection( &mut self, ) -> Result { @@ -1105,22 +1228,34 @@ impl FundingBuilderInner { } if self.value_added == Amount::ZERO { + let inputs = &self.manually_selected_inputs; + let input_mode = self.request_input_mode(); + + let total_input_value: Amount = + inputs.iter().map(|input| input.utxo.output.value).sum(); let estimated_fee = estimate_transaction_fee( - &[], + inputs, &self.outputs, None, true, self.shared_input.is_some(), self.feerate, ); + if !inputs.is_empty() { + total_input_value + .checked_sub(estimated_fee) + .ok_or(FundingContributionError::ManuallySelectedInputsInsufficient)?; + } + return Ok(FundingContribution { estimated_fee, - inputs: vec![], + inputs: core::mem::take(&mut self.manually_selected_inputs), 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(), + input_mode, }); } @@ -1169,10 +1304,31 @@ impl FundingBuilderInner { } } - if self.value_added == Amount::ZERO && self.outputs.is_empty() { + if self.value_added == Amount::ZERO + && self.manually_selected_inputs.is_empty() + && self.outputs.is_empty() + { return Err(FundingContributionError::InvalidSpliceValue); } + if !self.manually_selected_inputs.is_empty() && self.value_added > Amount::ZERO { + // Manually selected inputs are a separate request mode from asking coin selection to add + // more value to the channel. + return Err(FundingContributionError::InvalidSpliceValue); + } + + if let Some(PriorContribution { contribution: prior_contribution, .. }) = + self.prior_contribution.as_ref() + { + if prior_contribution.input_mode == Some(FundingInputMode::CoinSelected) + && !self.manually_selected_inputs.is_empty() + { + // Our prior contribution used coin selection to determine its inputs, but we're + // adding manually selected inputs, which is not allowed. + 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() @@ -1181,6 +1337,8 @@ impl FundingBuilderInner { return Err(FundingContributionError::InvalidSpliceValue); } + validate_inputs(&self.manually_selected_inputs)?; + let mut value_removed = Amount::ZERO; for output in self.outputs.iter() { value_removed = match value_removed.checked_add(output.value) { @@ -1196,12 +1354,18 @@ impl FundingBuilderInner { 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() { + let (value_added, manually_selected_inputs, outputs) = match prior_contribution.as_ref() { Some(prior) => { let outputs = prior.contribution.outputs.clone(); - (prior.contribution.value_added(), outputs) + if prior.contribution.input_mode == Some(FundingInputMode::Manual) { + // `value_added` is intended for coin selection, which is incompatible with + // manual input selection. + (Amount::ZERO, prior.contribution.inputs.clone(), outputs) + } else { + (prior.contribution.value_added(), Vec::new(), outputs) + } }, - None => (Amount::ZERO, Vec::new()), + None => (Amount::ZERO, Vec::new(), Vec::new()), }; FundingBuilder(FundingBuilderInner { @@ -1210,6 +1374,7 @@ impl FundingBuilder { prior_contribution, value_added, outputs, + manually_selected_inputs, feerate, max_feerate, state: NoCoinSelectionSource, @@ -1219,7 +1384,8 @@ impl FundingBuilder { /// 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. + /// reusing a prior contribution, by using only manually selected inputs, or by constructing a + /// pure splice-out directly. pub fn with_coin_selection_source( self, wallet: W, ) -> AsyncFundingBuilder { @@ -1229,13 +1395,52 @@ impl FundingBuilder { /// 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. + /// reusing a prior contribution, by using only manually selected inputs, or by constructing a + /// pure splice-out directly. pub fn with_coin_selection_source_sync( self, wallet: W, ) -> SyncFundingBuilder { SyncFundingBuilder(self.0.with_state(SyncCoinSelectionSource(wallet))) } + /// Adds a manually selected input to the request. + /// + /// Each input is fully consumed with no change output. When built without additional coin + /// selection, the inputs and explicit outputs are modeled by their net effect on the channel: + /// the contribution may be net-positive or net-negative before fees. + /// + /// Manually selected inputs are a separate request mode and cannot be combined with requesting + /// additional coin-selected value. If the manually selected inputs cannot satisfy the request, + /// [`FundingBuilder::build`] returns + /// [`FundingContributionError::ManuallySelectedInputsInsufficient`] instead of falling back to + /// coin selection. + pub fn add_input(mut self, input: FundingTxInput) -> Self { + self.0.manually_selected_inputs.push(input); + self + } + + /// Adds manually selected inputs to the request. + /// + /// Each input is fully consumed with no change output. When built without additional coin + /// selection, the inputs and explicit outputs are modeled by their net effect on the channel: + /// the contribution may be net-positive or net-negative before fees. + /// + /// Manually selected inputs are a separate request mode and cannot be combined with requesting + /// additional coin-selected value. If the manually selected inputs cannot satisfy the request, + /// [`FundingBuilder::build`] returns + /// [`FundingContributionError::ManuallySelectedInputsInsufficient`] instead of falling back to + /// coin selection. + pub fn add_inputs(mut self, inputs: Vec) -> Self { + self.0.manually_selected_inputs.extend(inputs); + self + } + + /// Removes all manually selected inputs whose outpoint matches `outpoint`. + pub fn remove_input(mut self, outpoint: &OutPoint) -> Self { + self.0.manually_selected_inputs.retain(|input| input.utxo.outpoint != *outpoint); + self + } + /// Adds a withdrawal output to the request. /// /// `output` is appended to the current set of explicit outputs. If the builder was seeded from @@ -1265,11 +1470,12 @@ impl FundingBuilder { /// 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. + /// contribution, by using only manually selected inputs, or by building a splice-out + /// contribution that pays fees from the channel balance. /// /// Returns [`FundingContributionError::MissingCoinSelectionSource`] if additional wallet - /// inputs are needed. + /// inputs are needed, or [`FundingContributionError::ManuallySelectedInputsInsufficient`] if + /// the manually selected inputs cannot satisfy the request. pub fn build(mut self) -> Result { self.0.build_without_coin_selection() } @@ -1282,6 +1488,8 @@ impl FundingBuilderInner { min_rbf_feerate: self.min_rbf_feerate, prior_contribution: self.prior_contribution, value_added: self.value_added, + manually_selected_inputs: self.manually_selected_inputs, + outputs: self.outputs, feerate: self.feerate, max_feerate: self.max_feerate, @@ -1320,7 +1528,9 @@ impl FundingBuilderInner { /// inputs. /// /// Returns [`FundingContributionError::MissingCoinSelectionSource`] if the request is valid but - /// cannot be satisfied without wallet inputs. + /// cannot be satisfied without wallet inputs, or + /// [`FundingContributionError::ManuallySelectedInputsInsufficient`] if the manually selected + /// inputs cannot satisfy the request. fn build_without_coin_selection( &mut self, ) -> Result { @@ -1382,7 +1592,8 @@ impl AsyncFundingBuilder { /// 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. + /// a pure splice-out directly, or by using only manually selected inputs, the attached wallet is + /// ignored. pub async fn build(self) -> Result { let mut inner = self.0; match inner.build_without_coin_selection() { @@ -1425,6 +1636,7 @@ impl AsyncFundingBuilder { feerate: inner.feerate, max_feerate: inner.max_feerate, is_splice, + input_mode: Some(FundingInputMode::CoinSelected), }); } } @@ -1482,7 +1694,8 @@ impl SyncFundingBuilder { /// 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. + /// a pure splice-out directly, or by using only manually selected inputs, the attached wallet is + /// ignored. pub fn build(self) -> Result { let mut inner = self.0; match inner.build_without_coin_selection() { @@ -1524,6 +1737,7 @@ impl SyncFundingBuilder { feerate: inner.feerate, max_feerate: inner.max_feerate, is_splice, + input_mode: Some(FundingInputMode::CoinSelected), }); } } @@ -1532,7 +1746,8 @@ impl SyncFundingBuilder { mod tests { use super::{ estimate_transaction_fee, FeeRateAdjustmentError, FundingBuilder, FundingContribution, - FundingContributionError, FundingTemplate, FundingTxInput, PriorContribution, + FundingContributionError, FundingInputMode, FundingTemplate, FundingTxInput, + PriorContribution, SyncCoinSelectionSource, SyncFundingBuilder, }; use crate::chain::ClaimId; use crate::util::wallet_utils::{CoinSelection, CoinSelectionSourceSync, Input}; @@ -1738,6 +1953,7 @@ mod tests { feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; let delta = Amount::from_sat(change.value.to_sat() - dust_limit.to_sat() + 1); @@ -1862,6 +2078,383 @@ mod tests { assert_eq!(contribution.value_added(), Amount::ZERO); } + #[test] + fn test_funding_builder_builds_manual_input_contribution_without_change() { + let feerate = FeeRate::from_sat_per_kwu(2000); + let input = funding_input_sats(100_000); + let output = funding_output_sats(25_000); + + let contribution = FundingTemplate::new(None, None, None) + .without_prior_contribution(feerate, FeeRate::MAX) + .add_input(input.clone()) + .add_output(output.clone()) + .build() + .unwrap(); + + let expected_fee = estimate_transaction_fee( + std::slice::from_ref(&input), + std::slice::from_ref(&output), + None, + true, + false, + feerate, + ); + assert_eq!(contribution.inputs, vec![input]); + assert_eq!(contribution.outputs, vec![output.clone()]); + assert!(contribution.change_output.is_none()); + assert_eq!(contribution.input_mode, Some(FundingInputMode::Manual)); + assert_eq!(contribution.estimated_fee, expected_fee); + assert_eq!( + contribution.value_added(), + Amount::from_sat(100_000) - output.value - expected_fee, + ); + assert_eq!( + contribution.net_value(), + Amount::from_sat(100_000).to_signed().unwrap() + - output.value.to_signed().unwrap() + - expected_fee.to_signed().unwrap(), + ); + } + + #[test] + fn test_funding_builder_add_inputs_builds_manual_input_contribution() { + let feerate = FeeRate::from_sat_per_kwu(2000); + let first_input = funding_input_sats(40_000); + let second_input = funding_input_sats(60_000); + let output = funding_output_sats(25_000); + + let contribution = FundingTemplate::new(None, None, None) + .without_prior_contribution(feerate, FeeRate::MAX) + .add_inputs(vec![first_input.clone(), second_input.clone()]) + .add_output(output.clone()) + .build() + .unwrap(); + + let expected_fee = estimate_transaction_fee( + &[first_input.clone(), second_input.clone()], + std::slice::from_ref(&output), + None, + true, + false, + feerate, + ); + assert_eq!(contribution.inputs, vec![first_input, second_input]); + assert_eq!(contribution.outputs, vec![output.clone()]); + assert!(contribution.change_output.is_none()); + assert_eq!(contribution.input_mode, Some(FundingInputMode::Manual)); + assert_eq!(contribution.estimated_fee, expected_fee); + assert_eq!( + contribution.value_added(), + Amount::from_sat(100_000) - output.value - expected_fee, + ); + } + + #[test] + fn test_funding_builder_remove_input_updates_manual_input_request() { + let feerate = FeeRate::from_sat_per_kwu(2000); + let first_input = funding_input_sats(40_000); + let second_input = funding_input_sats(60_000); + let output = funding_output_sats(25_000); + + let contribution = FundingTemplate::new(None, None, None) + .without_prior_contribution(feerate, FeeRate::MAX) + .add_inputs(vec![first_input.clone(), second_input.clone()]) + .remove_input(&first_input.utxo.outpoint) + .add_output(output.clone()) + .build() + .unwrap(); + + let expected_fee = estimate_transaction_fee( + std::slice::from_ref(&second_input), + std::slice::from_ref(&output), + None, + true, + false, + feerate, + ); + assert_eq!(contribution.inputs, vec![second_input]); + assert_eq!(contribution.outputs, vec![output.clone()]); + assert_eq!(contribution.input_mode, Some(FundingInputMode::Manual)); + assert_eq!( + contribution.value_added(), + Amount::from_sat(60_000) - output.value - expected_fee, + ); + } + + #[test] + fn test_splice_in_inputs_builds_manual_input_contribution() { + let feerate = FeeRate::from_sat_per_kwu(2000); + let first_input = funding_input_sats(40_000); + let second_input = funding_input_sats(60_000); + + let contribution = FundingTemplate::new(None, None, None) + .splice_in_inputs( + vec![first_input.clone(), second_input.clone()], + feerate, + FeeRate::MAX, + ) + .unwrap(); + + let expected_fee = estimate_transaction_fee( + &[first_input.clone(), second_input.clone()], + &[], + None, + true, + false, + feerate, + ); + assert_eq!(contribution.inputs, vec![first_input, second_input]); + assert!(contribution.outputs.is_empty()); + assert!(contribution.change_output.is_none()); + assert_eq!(contribution.input_mode, Some(FundingInputMode::Manual)); + assert_eq!(contribution.value_added(), Amount::from_sat(100_000) - expected_fee); + } + + #[test] + fn test_splice_in_inputs_appends_to_prior_manual_inputs() { + let feerate = FeeRate::from_sat_per_kwu(2000); + let prior_input = funding_input_sats(40_000); + let additional_input = funding_input_sats(60_000); + let prior_fee = estimate_transaction_fee( + std::slice::from_ref(&prior_input), + &[], + None, + true, + false, + feerate, + ); + let prior = FundingContribution { + estimated_fee: prior_fee, + inputs: vec![prior_input.clone()], + outputs: vec![], + change_output: None, + feerate, + max_feerate: FeeRate::MAX, + is_splice: false, + input_mode: Some(FundingInputMode::Manual), + }; + + let contribution = FundingTemplate::new( + None, + None, + Some(PriorContribution::new(prior, Amount::MAX_MONEY)), + ) + .splice_in_inputs(vec![additional_input.clone()], feerate, FeeRate::MAX) + .unwrap(); + + assert_eq!(contribution.inputs, vec![prior_input, additional_input]); + assert!(contribution.outputs.is_empty()); + assert_eq!(contribution.input_mode, Some(FundingInputMode::Manual)); + } + + #[test] + fn test_sync_funding_builder_manual_inputs_insufficient_do_not_fallback_to_coin_selection() { + let feerate = FeeRate::from_sat_per_kwu(2000); + let builder = FundingTemplate::new(None, None, None) + .without_prior_contribution(feerate, FeeRate::MAX) + .add_input(funding_input_sats(1)); + let builder = + SyncFundingBuilder(builder.0.with_state(SyncCoinSelectionSource(UnreachableWallet))); + + assert!(matches!( + builder.build(), + Err(FundingContributionError::ManuallySelectedInputsInsufficient), + )); + } + + #[test] + fn test_funding_builder_rejects_manual_inputs_with_value_request() { + let feerate = FeeRate::from_sat_per_kwu(2000); + let builder = FundingTemplate::new(None, None, None) + .without_prior_contribution(feerate, FeeRate::MAX) + .add_input(funding_input_sats(100_000)); + let builder = FundingBuilder(builder.0.add_value_inner(Amount::from_sat(1_000))); + + assert!(matches!(builder.build(), Err(FundingContributionError::InvalidSpliceValue),)); + } + + #[test] + fn test_funding_builder_rejects_manual_inputs_on_coin_selected_prior() { + let feerate = FeeRate::from_sat_per_kwu(2000); + let prior = FundingContribution { + estimated_fee: Amount::from_sat(1_000), + inputs: vec![funding_input_sats(100_000)], + outputs: vec![], + change_output: Some(funding_output_sats(10_000)), + feerate, + max_feerate: FeeRate::MAX, + is_splice: false, + input_mode: Some(FundingInputMode::CoinSelected), + }; + + let builder = FundingTemplate::new( + None, + None, + Some(PriorContribution::new(prior, Amount::MAX_MONEY)), + ) + .with_prior_contribution(feerate, FeeRate::MAX) + .add_input(funding_input_sats(50_000)); + + assert!(matches!(builder.build(), Err(FundingContributionError::InvalidSpliceValue),)); + } + + #[test] + fn test_funding_builder_validates_manual_input_max_money() { + let feerate = FeeRate::from_sat_per_kwu(2000); + let inputs = vec![funding_input_sats(Amount::MAX_MONEY.to_sat()), funding_input_sats(1)]; + + let builder = FundingTemplate::new(None, None, None) + .without_prior_contribution(feerate, FeeRate::MAX) + .add_inputs(inputs); + + assert!(matches!(builder.build(), Err(FundingContributionError::InvalidSpliceValue),)); + } + + #[test] + fn test_build_from_prior_manual_inputs_exact_match_reuses_and_adjusts() { + let original_feerate = FeeRate::from_sat_per_kwu(2000); + let target_feerate = FeeRate::from_sat_per_kwu(3000); + let input = funding_input_sats(100_000); + let output = funding_output_sats(20_000); + let estimated_fee = estimate_transaction_fee( + std::slice::from_ref(&input), + std::slice::from_ref(&output), + None, + true, + false, + original_feerate, + ); + let prior = FundingContribution { + estimated_fee, + inputs: vec![input.clone()], + outputs: vec![output.clone()], + change_output: None, + feerate: original_feerate, + max_feerate: FeeRate::MAX, + is_splice: false, + input_mode: Some(FundingInputMode::Manual), + }; + + let contribution = FundingTemplate::new( + None, + None, + Some(PriorContribution::new(prior, Amount::MAX_MONEY)), + ) + .with_prior_contribution(target_feerate, FeeRate::MAX) + .build() + .unwrap(); + + assert_eq!(contribution.inputs, vec![input]); + assert_eq!(contribution.outputs, vec![output]); + assert_eq!(contribution.feerate, target_feerate); + assert_eq!(contribution.input_mode, Some(FundingInputMode::Manual)); + } + + #[test] + fn test_build_from_prior_manual_inputs_changed_request_insufficient_maps_error() { + let feerate = FeeRate::from_sat_per_kwu(2000); + let input = funding_input_sats(50_000); + let estimated_fee = + estimate_transaction_fee(std::slice::from_ref(&input), &[], None, true, false, feerate); + let prior = FundingContribution { + estimated_fee, + inputs: vec![input], + outputs: vec![], + change_output: None, + feerate, + max_feerate: FeeRate::MAX, + is_splice: false, + input_mode: Some(FundingInputMode::Manual), + }; + + let result = + FundingTemplate::new(None, None, Some(PriorContribution::new(prior, Amount::ZERO))) + .with_prior_contribution(feerate, FeeRate::MAX) + .add_output(funding_output_sats(60_000)) + .build(); + + assert!(matches!( + result, + Err(FundingContributionError::ManuallySelectedInputsInsufficient), + )); + } + + #[test] + fn test_for_acceptor_at_feerate_manual_inputs_balance_insufficient() { + let original_feerate = FeeRate::from_sat_per_kwu(2000); + let target_feerate = FeeRate::from_sat_per_kwu(100_000); + let inputs = vec![funding_input_sats(100_000)]; + let outputs = vec![funding_output_sats(80_000)]; + let net_value_without_fee = Amount::from_sat(20_000); + + let estimated_fee = + estimate_transaction_fee(&inputs, &outputs, None, true, true, original_feerate); + let target_fee = + estimate_transaction_fee(&inputs, &outputs, None, false, true, target_feerate); + assert!(target_fee > net_value_without_fee); + + let contribution = FundingContribution { + estimated_fee, + inputs, + outputs, + change_output: None, + feerate: original_feerate, + max_feerate: FeeRate::MAX, + is_splice: true, + input_mode: Some(FundingInputMode::Manual), + }; + + let holder_balance = target_fee + .checked_sub(net_value_without_fee) + .and_then(|shortfall| shortfall.checked_sub(Amount::from_sat(1))) + .unwrap(); + match contribution.for_acceptor_at_feerate(target_feerate, holder_balance) { + Err(FeeRateAdjustmentError::FeeBufferInsufficient { source, available, required }) => { + assert_eq!(source, "channel balance"); + assert_eq!(available, target_fee - Amount::from_sat(1)); + assert_eq!(required, target_fee); + }, + other => panic!("Expected channel-balance shortfall, got {other:?}"), + } + } + + #[test] + fn test_for_acceptor_at_feerate_manual_inputs_balance_sufficient() { + let original_feerate = FeeRate::from_sat_per_kwu(2000); + let target_feerate = FeeRate::from_sat_per_kwu(100_000); + let inputs = vec![funding_input_sats(100_000)]; + let outputs = vec![funding_output_sats(80_000)]; + let net_value_without_fee = Amount::from_sat(20_000); + + let estimated_fee = + estimate_transaction_fee(&inputs, &outputs, None, true, true, original_feerate); + let target_fee = + estimate_transaction_fee(&inputs, &outputs, None, false, true, target_feerate); + + let contribution = FundingContribution { + estimated_fee, + inputs: inputs.clone(), + outputs: outputs.clone(), + change_output: None, + feerate: original_feerate, + max_feerate: FeeRate::MAX, + is_splice: true, + input_mode: Some(FundingInputMode::Manual), + }; + + let holder_balance = target_fee.checked_sub(net_value_without_fee).unwrap(); + let adjusted = + contribution.for_acceptor_at_feerate(target_feerate, holder_balance).unwrap(); + + assert_eq!(adjusted.inputs, inputs); + assert_eq!(adjusted.outputs, outputs); + assert_eq!(adjusted.estimated_fee, target_fee); + assert_eq!( + adjusted.net_value(), + net_value_without_fee.to_signed().unwrap() - target_fee.to_signed().unwrap(), + ); + } + #[test] fn test_build_funding_contribution_validates_max_money() { let over_max = Amount::MAX_MONEY + Amount::from_sat(1); @@ -2012,6 +2605,7 @@ mod tests { feerate: original_feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; let net_value_before = contribution.net_value(); @@ -2049,6 +2643,7 @@ mod tests { feerate: original_feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; let result = contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX); @@ -2089,6 +2684,7 @@ mod tests { feerate: original_feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; let net_value_before = contribution.net_value(); @@ -2124,6 +2720,7 @@ mod tests { feerate: original_feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; let result = contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX); @@ -2149,6 +2746,7 @@ mod tests { feerate: original_feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; let contribution = @@ -2178,6 +2776,7 @@ mod tests { feerate: original_feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; // Balance of 55,000 sats can't cover outputs (50,000) + target_fee at 50k sat/kwu. @@ -2207,6 +2806,7 @@ mod tests { feerate: original_feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; // For splice-in with change that stays above dust, the surplus is absorbed by the change @@ -2239,6 +2839,7 @@ mod tests { feerate: original_feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; let net_at_feerate = @@ -2274,6 +2875,7 @@ mod tests { feerate: original_feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; let net_before = contribution.net_value(); @@ -2307,6 +2909,7 @@ mod tests { feerate: original_feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; let result = contribution.net_value_for_acceptor_at_feerate(target_feerate, Amount::MAX); @@ -2334,6 +2937,7 @@ mod tests { feerate: original_feerate, max_feerate, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; let result = contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX); @@ -2365,6 +2969,7 @@ mod tests { feerate: original_feerate, max_feerate, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; let result = contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX); @@ -2399,6 +3004,7 @@ mod tests { feerate: original_feerate, max_feerate, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; let result = contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX); @@ -2441,6 +3047,7 @@ mod tests { feerate: original_feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; let result = contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX); @@ -2473,6 +3080,7 @@ mod tests { feerate: original_feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; let result = contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX); @@ -2511,6 +3119,7 @@ mod tests { feerate: original_feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; let result = contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX); @@ -2547,6 +3156,7 @@ mod tests { feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; // target == min feerate, so FeeRateTooLow check passes. @@ -2574,6 +3184,7 @@ mod tests { feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; let result = contribution.for_acceptor_at_feerate(feerate, Amount::MAX); @@ -2598,6 +3209,7 @@ mod tests { feerate: original_feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; // Balance of 40,000 sats is less than outputs (50,000) + target_fee. @@ -2624,6 +3236,7 @@ mod tests { feerate: original_feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; // Balance of 100,000 sats is more than outputs (50,000) + target_fee. @@ -2654,6 +3267,7 @@ mod tests { feerate: original_feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; // Balance of 40,000 sats is less than outputs (50,000) + target_fee. @@ -2682,6 +3296,7 @@ mod tests { feerate: original_feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; let acceptor = @@ -2716,6 +3331,7 @@ mod tests { feerate: prior_feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; // max_feerate (2020) < min_rbf_feerate (2025). @@ -2752,6 +3368,7 @@ mod tests { feerate: prior_feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; let template = FundingTemplate::new( @@ -2785,6 +3402,7 @@ mod tests { feerate: prior_feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; let template = FundingTemplate::new( @@ -2813,6 +3431,7 @@ mod tests { feerate: prior_feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; let template = FundingTemplate::new( @@ -2845,6 +3464,7 @@ mod tests { feerate: prior_feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; let template = FundingTemplate::new( @@ -2911,6 +3531,7 @@ mod tests { feerate: prior_feerate, max_feerate: prior_feerate, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; let template = FundingTemplate::new( @@ -2952,6 +3573,7 @@ mod tests { feerate: FeeRate::from_sat_per_kwu(2000), max_feerate: prior_max_feerate, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; let template = FundingTemplate::new( diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index fa22ccb61c7..67242cc308b 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -39,6 +39,7 @@ use bitcoin::hashes::Hash; use bitcoin::secp256k1::ecdsa::Signature; use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; use bitcoin::transaction::Version; +use bitcoin::SignedAmount; use bitcoin::{ Amount, FeeRate, OutPoint as BitcoinOutPoint, Psbt, ScriptBuf, Transaction, TxOut, Txid, WPubkeyHash, WScriptHash, @@ -5895,6 +5896,11 @@ fn test_splice_rbf_amends_prior_net_negative_contribution_request() { assert!(initial_inputs.is_empty()); let (splice_tx_0, new_funding_script) = splice_channel(&nodes[0], &nodes[1], channel_id, initial_contribution.clone()); + let manual_input_pair_tx = provide_utxo_reserves(&nodes, 2, Amount::from_sat(20_000)); + let manual_input_single_tx = provide_utxo_reserves(&nodes, 1, Amount::from_sat(10_000)); + let manual_input_0 = ConfirmedUtxo::new_p2wpkh(manual_input_pair_tx.clone(), 0).unwrap(); + let manual_input_1 = ConfirmedUtxo::new_p2wpkh(manual_input_pair_tx, 1).unwrap(); + let manual_input_2 = ConfirmedUtxo::new_p2wpkh(manual_input_single_tx, 0).unwrap(); let run_rbf_round = |contribution: FundingContribution| { nodes[0] @@ -5947,21 +5953,68 @@ fn test_splice_rbf_amends_prior_net_negative_contribution_request() { 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 rbf_feerate = funding_template.min_rbf_feerate().unwrap(); + let contribution_3 = funding_template + .with_prior_contribution(rbf_feerate, FeeRate::MAX) + .add_inputs(vec![manual_input_0.clone(), manual_input_1.clone()]) + .build() + .unwrap(); let (inputs_3, _) = contribution_3.clone().into_contributed_inputs_and_outputs(); - assert!(inputs_3.is_empty()); + assert_eq!(inputs_3, vec![manual_input_0.utxo.outpoint, manual_input_1.utxo.outpoint],); assert_eq!(contribution_3.outputs(), contribution_2.outputs()); - assert!(contribution_3.net_value() < contribution_2.net_value()); + assert!(contribution_3.net_value() > SignedAmount::ZERO); assert!(contribution_3.change_output().is_none()); - let rbf_tx_final = run_rbf_round(contribution_3); + 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 prior_inputs = funding_template + .prior_contribution() + .unwrap() + .clone() + .into_contributed_inputs_and_outputs() + .0; + assert_eq!(prior_inputs, vec![manual_input_0.utxo.outpoint, manual_input_1.utxo.outpoint],); + let rbf_feerate = funding_template.min_rbf_feerate().unwrap(); + let contribution_4 = funding_template + .with_prior_contribution(rbf_feerate, FeeRate::MAX) + .add_input(manual_input_2.clone()) + .remove_input(&manual_input_0.utxo.outpoint) + .remove_input(&manual_input_1.utxo.outpoint) + .build() + .unwrap(); + let (inputs_4, _) = contribution_4.clone().into_contributed_inputs_and_outputs(); + assert_eq!(inputs_4, vec![manual_input_2.utxo.outpoint]); + assert_eq!(contribution_4.outputs(), contribution_3.outputs()); + assert!(contribution_4.net_value() < SignedAmount::ZERO); + assert!(contribution_4.net_value() < contribution_3.net_value()); + assert!(contribution_4.change_output().is_none()); + let splice_tx_4 = run_rbf_round(contribution_4.clone()); + + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap(); + assert_eq!(funding_template.prior_contribution().unwrap().outputs(), contribution_4.outputs()); + let contribution_5 = + funding_template.rbf_prior_contribution_sync(None, FeeRate::MAX, &wallet).unwrap(); + let (inputs_5, _) = contribution_5.clone().into_contributed_inputs_and_outputs(); + assert_eq!(inputs_5, vec![manual_input_2.utxo.outpoint]); + assert_eq!(contribution_5.outputs(), contribution_4.outputs()); + assert!(contribution_5.net_value() < SignedAmount::ZERO); + assert!(contribution_5.net_value() < contribution_4.net_value()); + assert!(contribution_5.change_output().is_none()); + let rbf_tx_final = run_rbf_round(contribution_5); 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_0.compute_txid(), + splice_tx_1.compute_txid(), + splice_tx_2.compute_txid(), + splice_tx_3.compute_txid(), + splice_tx_4.compute_txid(), + ], ); } From 228cae85138c2d8d9353ffae5a80258a82fdcaff Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Tue, 28 Apr 2026 14:25:49 -0700 Subject: [PATCH 2/2] Include spliceable balance in every FundingTemplate There's no reason not to do so, and it allows us to fail earlier when the user's net contribution exceeds their spliceable balance. --- lightning/src/ln/channel.rs | 32 ++-- lightning/src/ln/funding.rs | 351 ++++++++++++++++++------------------ 2 files changed, 197 insertions(+), 186 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 10801edef01..015f59f072c 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -56,7 +56,7 @@ use crate::ln::channelmanager::{ MAX_LOCAL_BREAKDOWN_TIMEOUT, MIN_CLTV_EXPIRY_DELTA, }; use crate::ln::funding::{ - FeeRateAdjustmentError, FundingContribution, FundingTemplate, FundingTxInput, PriorContribution, + FeeRateAdjustmentError, FundingContribution, FundingTemplate, FundingTxInput, }; use crate::ln::interactivetxs::{ AbortReason, HandleTxCompleteValue, InteractiveTxConstructor, InteractiveTxConstructorArgs, @@ -12333,6 +12333,17 @@ where }); } + let spliceable_balance = self + .get_holder_counterparty_balances_floor_incl_fee(&self.funding) + .map(|(h, _)| h) + .map_err(|e| APIError::ChannelUnavailable { + err: format!( + "Channel {} cannot be spliced at this time: {}", + self.context.channel_id(), + e + ), + })?; + let (min_rbf_feerate, prior_contribution) = if self.is_rbf_compatible().is_err() { // Channel can never RBF (e.g., zero-conf). (None, None) @@ -12365,17 +12376,7 @@ where .as_ref() .and_then(|pending_splice| pending_splice.contributions.last()) { - let holder_balance = self - .get_holder_counterparty_balances_floor_incl_fee(&self.funding) - .map(|(h, _)| h) - .map_err(|e| APIError::ChannelUnavailable { - err: format!( - "Channel {} cannot be spliced at this time: {}", - self.context.channel_id(), - e - ), - })?; - Some(PriorContribution::new(prior.clone(), holder_balance)) + Some(prior.clone()) } else { None } @@ -12397,7 +12398,12 @@ where satisfaction_weight: EMPTY_SCRIPT_SIG_WEIGHT + FUNDING_TRANSACTION_WITNESS_WEIGHT, }; - Ok(FundingTemplate::new(Some(shared_input), min_rbf_feerate, prior_contribution)) + Ok(FundingTemplate::new( + Some(shared_input), + min_rbf_feerate, + prior_contribution, + spliceable_balance, + )) } /// Returns whether this channel can ever RBF, independent of splice state. diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index 5dd6fe2acea..e9ab883bb8d 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -132,7 +132,8 @@ pub enum FundingContributionError { /// The minimum RBF feerate. min_rbf_feerate: FeeRate, }, - /// The splice value is invalid (zero, empty outputs, or exceeds the maximum money supply). + /// The splice value is invalid (zero, empty outputs, exceeds the maximum money supply, or + /// splices out more than the available channel balance). InvalidSpliceValue, /// An input's `prevtx` is too large to fit in a `tx_add_input` message. PrevTxTooLarge, @@ -163,7 +164,7 @@ impl core::fmt::Display for FundingContributionError { write!(f, "Feerate {} is below minimum RBF feerate {}", feerate, min_rbf_feerate) }, FundingContributionError::InvalidSpliceValue => { - write!(f, "Invalid splice value (zero, empty, or exceeds limit)") + write!(f, "Invalid splice value (zero, empty, exceeds limit, or overdraws balance)") }, FundingContributionError::PrevTxTooLarge => { write!(f, "Input prevtx is too large to fit in a tx_add_input message") @@ -184,39 +185,6 @@ 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. 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 reuse it or replace it with -/// [`FundingTemplate::without_prior_contribution`]. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(super) struct PriorContribution { - contribution: FundingContribution, - /// The holder's balance, used for feerate adjustment. - /// - /// This value is captured at [`ChannelManager::splice_channel`] time and may become stale - /// if balances change before the contribution is used. Staleness is acceptable here because - /// this is only used as an optimization to determine if the prior contribution can be - /// reused with adjusted fees — the contribution is re-validated at - /// [`ChannelManager::funding_contributed`] time and again at quiescence time against the - /// current balances. - /// - /// [`ChannelManager::splice_channel`]: crate::ln::channelmanager::ChannelManager::splice_channel - /// [`ChannelManager::funding_contributed`]: crate::ln::channelmanager::ChannelManager::funding_contributed - holder_balance: Amount, -} - -impl PriorContribution { - pub(super) fn new(contribution: FundingContribution, holder_balance: Amount) -> Self { - Self { contribution, holder_balance } - } -} - /// A template for contributing to a channel's splice funding transaction. /// /// This is returned from [`ChannelManager::splice_channel`] when a channel is ready to be @@ -260,17 +228,30 @@ pub struct FundingTemplate { /// pending splice candidates. min_rbf_feerate: Option, - /// The user's prior contribution from a previous splice negotiation, if available. - prior_contribution: Option, + /// The user's prior contribution from a previous splice negotiation on this channel. + prior_contribution: Option, + + /// The portion of the user's balance that can be spliced out. + /// + /// This value is captured at [`ChannelManager::splice_channel`] time and may become stale + /// if balances change before the contribution is used. Staleness is acceptable here because + /// this is only used as an optimization to determine if the prior contribution can be + /// reused with adjusted fees — the contribution is re-validated at + /// [`ChannelManager::funding_contributed`] time and again at quiescence time against the + /// current balances. + /// + /// [`ChannelManager::splice_channel`]: crate::ln::channelmanager::ChannelManager::splice_channel + /// [`ChannelManager::funding_contributed`]: crate::ln::channelmanager::ChannelManager::funding_contributed + spliceable_balance: Amount, } impl FundingTemplate { /// Constructs a [`FundingTemplate`] for a splice using the provided shared input. pub(super) fn new( shared_input: Option, min_rbf_feerate: Option, - prior_contribution: Option, + prior_contribution: Option, spliceable_balance: Amount, ) -> Self { - Self { shared_input, min_rbf_feerate, prior_contribution } + Self { shared_input, min_rbf_feerate, prior_contribution, spliceable_balance } } /// Returns the minimum RBF feerate, if this template is for an RBF attempt. @@ -296,7 +277,7 @@ impl FundingTemplate { /// 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) + self.prior_contribution.as_ref() } /// Creates a [`FundingBuilder`] for constructing a contribution. @@ -1091,7 +1072,8 @@ struct SyncCoinSelectionSource(W); struct FundingBuilderInner { shared_input: Option, min_rbf_feerate: Option, - prior_contribution: Option, + prior_contribution: Option, + spliceable_balance: Amount, value_added: Amount, manually_selected_inputs: Vec, outputs: Vec, @@ -1161,16 +1143,15 @@ impl FundingBuilderInner { } fn build_from_prior_contribution( - &mut self, contribution: PriorContribution, + &mut self, contribution: FundingContribution, ) -> Result { - let PriorContribution { contribution, holder_balance } = contribution; let input_mode = self.request_input_mode(); 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) + .for_initiator_at_feerate(self.feerate, self.spliceable_balance) .map(|mut adjusted| { adjusted.max_feerate = self.max_feerate; adjusted @@ -1199,7 +1180,7 @@ impl FundingBuilderInner { &self.outputs, self.feerate, self.max_feerate, - holder_balance, + self.spliceable_balance, ) .ok_or_else(|| { if input_mode == Some(FundingInputMode::Manual) { @@ -1231,8 +1212,6 @@ impl FundingBuilderInner { let inputs = &self.manually_selected_inputs; let input_mode = self.request_input_mode(); - let total_input_value: Amount = - inputs.iter().map(|input| input.utxo.output.value).sum(); let estimated_fee = estimate_transaction_fee( inputs, &self.outputs, @@ -1241,13 +1220,8 @@ impl FundingBuilderInner { self.shared_input.is_some(), self.feerate, ); - if !inputs.is_empty() { - total_input_value - .checked_sub(estimated_fee) - .ok_or(FundingContributionError::ManuallySelectedInputsInsufficient)?; - } - return Ok(FundingContribution { + let contribution = FundingContribution { estimated_fee, inputs: core::mem::take(&mut self.manually_selected_inputs), outputs: core::mem::take(&mut self.outputs), @@ -1256,7 +1230,19 @@ impl FundingBuilderInner { max_feerate: self.max_feerate, is_splice: self.shared_input.is_some(), input_mode, - }); + }; + let net_value = contribution.net_value(); + if net_value.is_negative() { + self.spliceable_balance.checked_sub(net_value.unsigned_abs()).ok_or_else(|| { + if contribution.inputs.is_empty() { + FundingContributionError::InvalidSpliceValue + } else { + FundingContributionError::ManuallySelectedInputsInsufficient + } + })?; + } + + return Ok(contribution); } Err(FundingContributionError::MissingCoinSelectionSource) @@ -1317,9 +1303,7 @@ impl FundingBuilderInner { return Err(FundingContributionError::InvalidSpliceValue); } - if let Some(PriorContribution { contribution: prior_contribution, .. }) = - self.prior_contribution.as_ref() - { + if let Some(prior_contribution) = self.prior_contribution.as_ref() { if prior_contribution.input_mode == Some(FundingInputMode::CoinSelected) && !self.manually_selected_inputs.is_empty() { @@ -1353,16 +1337,21 @@ impl FundingBuilderInner { impl FundingBuilder { fn new(template: FundingTemplate, feerate: FeeRate, max_feerate: FeeRate) -> FundingBuilder { - let FundingTemplate { shared_input, min_rbf_feerate, prior_contribution } = template; + let FundingTemplate { + shared_input, + min_rbf_feerate, + prior_contribution, + spliceable_balance, + } = template; let (value_added, manually_selected_inputs, outputs) = match prior_contribution.as_ref() { Some(prior) => { - let outputs = prior.contribution.outputs.clone(); - if prior.contribution.input_mode == Some(FundingInputMode::Manual) { + let outputs = prior.outputs.clone(); + if prior.input_mode == Some(FundingInputMode::Manual) { // `value_added` is intended for coin selection, which is incompatible with // manual input selection. - (Amount::ZERO, prior.contribution.inputs.clone(), outputs) + (Amount::ZERO, prior.inputs.clone(), outputs) } else { - (prior.contribution.value_added(), Vec::new(), outputs) + (prior.value_added(), Vec::new(), outputs) } }, None => (Amount::ZERO, Vec::new(), Vec::new()), @@ -1372,6 +1361,7 @@ impl FundingBuilder { shared_input, min_rbf_feerate, prior_contribution, + spliceable_balance, value_added, outputs, manually_selected_inputs, @@ -1487,6 +1477,7 @@ impl FundingBuilderInner { shared_input: self.shared_input, min_rbf_feerate: self.min_rbf_feerate, prior_contribution: self.prior_contribution, + spliceable_balance: self.spliceable_balance, value_added: self.value_added, manually_selected_inputs: self.manually_selected_inputs, @@ -1747,7 +1738,7 @@ mod tests { use super::{ estimate_transaction_fee, FeeRateAdjustmentError, FundingBuilder, FundingContribution, FundingContributionError, FundingInputMode, FundingTemplate, FundingTxInput, - PriorContribution, SyncCoinSelectionSource, SyncFundingBuilder, + SyncCoinSelectionSource, SyncFundingBuilder, }; use crate::chain::ClaimId; use crate::util::wallet_utils::{CoinSelection, CoinSelectionSourceSync, Input}; @@ -1896,11 +1887,14 @@ mod tests { 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 contribution = FundingBuilder::new( + FundingTemplate::new(None, None, None, Amount::MAX), + feerate, + FeeRate::MAX, + ) + .add_output(output.clone()) + .build() + .unwrap(); let expected_fee = estimate_transaction_fee( &[], @@ -1920,11 +1914,38 @@ mod tests { ); } + #[test] + fn test_funding_builder_rejects_splice_out_over_balance() { + let feerate = FeeRate::from_sat_per_kwu(2000); + let output = funding_output_sats(25_000); + let expected_fee = estimate_transaction_fee( + &[], + std::slice::from_ref(&output), + None, + true, + false, + feerate, + ); + let exact_balance = output.value + expected_fee; + + let contribution = FundingTemplate::new(None, None, None, exact_balance) + .splice_out(vec![output.clone()], feerate, FeeRate::MAX) + .unwrap(); + assert_eq!(contribution.net_value(), -exact_balance.to_signed().unwrap()); + + let result = FundingTemplate::new(None, None, None, exact_balance - Amount::from_sat(1)) + .splice_out(vec![output], feerate, FeeRate::MAX); + assert!(matches!(result, Err(FundingContributionError::InvalidSpliceValue))); + } + #[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::new( + FundingTemplate::new(None, None, None, Amount::ZERO), + feerate, + FeeRate::MAX, + ); let builder = FundingBuilder(builder.0.add_value_inner(Amount::from_sat(25_000))); assert!(matches!( @@ -1967,9 +1988,8 @@ mod tests { 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 builder = FundingTemplate::new(None, None, Some(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()); @@ -1994,14 +2014,17 @@ mod tests { 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(); + let contribution = FundingBuilder::new( + FundingTemplate::new(None, None, None, Amount::MAX), + 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]); } @@ -2009,12 +2032,15 @@ mod tests { #[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 builder = FundingBuilder::new( + FundingTemplate::new(None, None, None, Amount::ZERO), + 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); @@ -2046,13 +2072,16 @@ mod tests { 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(); + let contribution = FundingBuilder::new( + FundingTemplate::new(None, None, None, Amount::MAX), + 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]); @@ -2063,14 +2092,17 @@ mod tests { 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(); + let contribution = FundingBuilder::new( + FundingTemplate::new(None, None, None, Amount::MAX), + 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]); @@ -2084,7 +2116,7 @@ mod tests { let input = funding_input_sats(100_000); let output = funding_output_sats(25_000); - let contribution = FundingTemplate::new(None, None, None) + let contribution = FundingTemplate::new(None, None, None, Amount::ZERO) .without_prior_contribution(feerate, FeeRate::MAX) .add_input(input.clone()) .add_output(output.clone()) @@ -2123,7 +2155,7 @@ mod tests { let second_input = funding_input_sats(60_000); let output = funding_output_sats(25_000); - let contribution = FundingTemplate::new(None, None, None) + let contribution = FundingTemplate::new(None, None, None, Amount::ZERO) .without_prior_contribution(feerate, FeeRate::MAX) .add_inputs(vec![first_input.clone(), second_input.clone()]) .add_output(output.clone()) @@ -2156,7 +2188,7 @@ mod tests { let second_input = funding_input_sats(60_000); let output = funding_output_sats(25_000); - let contribution = FundingTemplate::new(None, None, None) + let contribution = FundingTemplate::new(None, None, None, Amount::ZERO) .without_prior_contribution(feerate, FeeRate::MAX) .add_inputs(vec![first_input.clone(), second_input.clone()]) .remove_input(&first_input.utxo.outpoint) @@ -2187,7 +2219,7 @@ mod tests { let first_input = funding_input_sats(40_000); let second_input = funding_input_sats(60_000); - let contribution = FundingTemplate::new(None, None, None) + let contribution = FundingTemplate::new(None, None, None, Amount::ZERO) .splice_in_inputs( vec![first_input.clone(), second_input.clone()], feerate, @@ -2234,13 +2266,9 @@ mod tests { input_mode: Some(FundingInputMode::Manual), }; - let contribution = FundingTemplate::new( - None, - None, - Some(PriorContribution::new(prior, Amount::MAX_MONEY)), - ) - .splice_in_inputs(vec![additional_input.clone()], feerate, FeeRate::MAX) - .unwrap(); + let contribution = FundingTemplate::new(None, None, Some(prior), Amount::MAX_MONEY) + .splice_in_inputs(vec![additional_input.clone()], feerate, FeeRate::MAX) + .unwrap(); assert_eq!(contribution.inputs, vec![prior_input, additional_input]); assert!(contribution.outputs.is_empty()); @@ -2250,7 +2278,7 @@ mod tests { #[test] fn test_sync_funding_builder_manual_inputs_insufficient_do_not_fallback_to_coin_selection() { let feerate = FeeRate::from_sat_per_kwu(2000); - let builder = FundingTemplate::new(None, None, None) + let builder = FundingTemplate::new(None, None, None, Amount::ZERO) .without_prior_contribution(feerate, FeeRate::MAX) .add_input(funding_input_sats(1)); let builder = @@ -2265,7 +2293,7 @@ mod tests { #[test] fn test_funding_builder_rejects_manual_inputs_with_value_request() { let feerate = FeeRate::from_sat_per_kwu(2000); - let builder = FundingTemplate::new(None, None, None) + let builder = FundingTemplate::new(None, None, None, Amount::ZERO) .without_prior_contribution(feerate, FeeRate::MAX) .add_input(funding_input_sats(100_000)); let builder = FundingBuilder(builder.0.add_value_inner(Amount::from_sat(1_000))); @@ -2287,13 +2315,9 @@ mod tests { input_mode: Some(FundingInputMode::CoinSelected), }; - let builder = FundingTemplate::new( - None, - None, - Some(PriorContribution::new(prior, Amount::MAX_MONEY)), - ) - .with_prior_contribution(feerate, FeeRate::MAX) - .add_input(funding_input_sats(50_000)); + let builder = FundingTemplate::new(None, None, Some(prior), Amount::MAX_MONEY) + .with_prior_contribution(feerate, FeeRate::MAX) + .add_input(funding_input_sats(50_000)); assert!(matches!(builder.build(), Err(FundingContributionError::InvalidSpliceValue),)); } @@ -2303,7 +2327,7 @@ mod tests { let feerate = FeeRate::from_sat_per_kwu(2000); let inputs = vec![funding_input_sats(Amount::MAX_MONEY.to_sat()), funding_input_sats(1)]; - let builder = FundingTemplate::new(None, None, None) + let builder = FundingTemplate::new(None, None, None, Amount::ZERO) .without_prior_contribution(feerate, FeeRate::MAX) .add_inputs(inputs); @@ -2335,14 +2359,10 @@ mod tests { input_mode: Some(FundingInputMode::Manual), }; - let contribution = FundingTemplate::new( - None, - None, - Some(PriorContribution::new(prior, Amount::MAX_MONEY)), - ) - .with_prior_contribution(target_feerate, FeeRate::MAX) - .build() - .unwrap(); + let contribution = FundingTemplate::new(None, None, Some(prior), Amount::MAX_MONEY) + .with_prior_contribution(target_feerate, FeeRate::MAX) + .build() + .unwrap(); assert_eq!(contribution.inputs, vec![input]); assert_eq!(contribution.outputs, vec![output]); @@ -2367,11 +2387,10 @@ mod tests { input_mode: Some(FundingInputMode::Manual), }; - let result = - FundingTemplate::new(None, None, Some(PriorContribution::new(prior, Amount::ZERO))) - .with_prior_contribution(feerate, FeeRate::MAX) - .add_output(funding_output_sats(60_000)) - .build(); + let result = FundingTemplate::new(None, None, Some(prior), Amount::ZERO) + .with_prior_contribution(feerate, FeeRate::MAX) + .add_output(funding_output_sats(60_000)) + .build(); assert!(matches!( result, @@ -2462,7 +2481,7 @@ mod tests { // splice_in_sync with value_added > MAX_MONEY { - let template = FundingTemplate::new(None, None, None); + let template = FundingTemplate::new(None, None, None, Amount::ZERO); assert!(matches!( template.splice_in_sync(over_max, feerate, feerate, UnreachableWallet), Err(FundingContributionError::InvalidSpliceValue), @@ -2471,7 +2490,7 @@ mod tests { // splice_out with single output value > MAX_MONEY { - let template = FundingTemplate::new(None, None, None); + let template = FundingTemplate::new(None, None, None, Amount::ZERO); let outputs = vec![funding_output_sats(over_max.to_sat())]; assert!(matches!( template.splice_out(outputs, feerate, feerate), @@ -2481,7 +2500,7 @@ mod tests { // splice_out with multiple outputs summing > MAX_MONEY { - let template = FundingTemplate::new(None, None, None); + let template = FundingTemplate::new(None, None, None, Amount::ZERO); let half_over = Amount::MAX_MONEY / 2 + Amount::from_sat(1); let outputs = vec![ funding_output_sats(half_over.to_sat()), @@ -2501,7 +2520,7 @@ mod tests { // Mixed add/remove request with value_added > MAX_MONEY. assert!(matches!( - FundingTemplate::new(None, None, None) + FundingTemplate::new(None, None, None, Amount::ZERO) .without_prior_contribution(feerate, feerate) .with_coin_selection_source_sync(UnreachableWallet) .add_value(over_max) @@ -2513,7 +2532,7 @@ mod tests { // 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) + FundingTemplate::new(None, None, None, Amount::ZERO) .without_prior_contribution(feerate, feerate) .with_coin_selection_source_sync(UnreachableWallet) .add_value(Amount::from_sat(1_000)) @@ -2533,7 +2552,7 @@ mod tests { // min_feerate > max_feerate is rejected { - let template = FundingTemplate::new(None, None, None); + let template = FundingTemplate::new(None, None, None, Amount::ZERO); assert!(matches!( template.splice_in_sync(Amount::from_sat(10_000), high, low, UnreachableWallet), Err(FundingContributionError::FeeRateExceedsMaximum { .. }), @@ -2542,7 +2561,7 @@ mod tests { // min_feerate < min_rbf_feerate is rejected { - let template = FundingTemplate::new(None, Some(high), None); + let template = FundingTemplate::new(None, Some(high), None, Amount::ZERO); assert!(matches!( template.splice_in_sync( Amount::from_sat(10_000), @@ -2573,7 +2592,7 @@ mod tests { change_output: None, }; assert!(matches!( - FundingTemplate::new(None, None, None) + FundingTemplate::new(None, None, None, Amount::ZERO) .with_prior_contribution(feerate, feerate) .with_coin_selection_source_sync(wallet) .add_value(Amount::from_sat(10_000)) @@ -3335,11 +3354,7 @@ mod tests { }; // max_feerate (2020) < min_rbf_feerate (2025). - let template = FundingTemplate::new( - None, - Some(min_rbf_feerate), - Some(PriorContribution::new(prior, Amount::MAX)), - ); + let template = FundingTemplate::new(None, Some(min_rbf_feerate), Some(prior), Amount::MAX); assert!(matches!( template.rbf_prior_contribution_sync(None, max_feerate, UnreachableWallet), Err(FundingContributionError::FeeRateExceedsMaximum { .. }), @@ -3371,11 +3386,7 @@ mod tests { input_mode: Some(FundingInputMode::CoinSelected), }; - let template = FundingTemplate::new( - None, - Some(min_rbf_feerate), - Some(PriorContribution::new(prior, Amount::MAX)), - ); + let template = FundingTemplate::new(None, Some(min_rbf_feerate), Some(prior), Amount::MAX); let contribution = template.rbf_prior_contribution_sync(None, max_feerate, UnreachableWallet).unwrap(); assert_eq!(contribution.feerate, min_rbf_feerate); @@ -3405,11 +3416,7 @@ mod tests { input_mode: Some(FundingInputMode::CoinSelected), }; - let template = FundingTemplate::new( - None, - Some(min_rbf_feerate), - Some(PriorContribution::new(prior, Amount::MAX)), - ); + let template = FundingTemplate::new(None, Some(min_rbf_feerate), Some(prior), Amount::MAX); let contribution = template .rbf_prior_contribution_sync(Some(override_feerate), max_feerate, UnreachableWallet) .unwrap(); @@ -3434,11 +3441,7 @@ mod tests { input_mode: Some(FundingInputMode::CoinSelected), }; - let template = FundingTemplate::new( - None, - Some(min_rbf_feerate), - Some(PriorContribution::new(prior, Amount::MAX)), - ); + let template = FundingTemplate::new(None, Some(min_rbf_feerate), Some(prior), Amount::MAX); assert!(matches!( template.rbf_prior_contribution_sync( Some(override_feerate), @@ -3467,11 +3470,7 @@ mod tests { input_mode: Some(FundingInputMode::CoinSelected), }; - let template = FundingTemplate::new( - None, - Some(min_rbf_feerate), - Some(PriorContribution::new(prior, Amount::MAX)), - ); + let template = FundingTemplate::new(None, Some(min_rbf_feerate), Some(prior), Amount::MAX); assert!(matches!( template.rbf_prior_contribution_sync( Some(override_feerate), @@ -3537,7 +3536,8 @@ mod tests { let template = FundingTemplate::new( Some(shared_input(100_000)), Some(min_rbf_feerate), - Some(PriorContribution::new(prior, Amount::ZERO)), + Some(prior), + Amount::ZERO, ); let wallet = SingleUtxoWallet { @@ -3579,7 +3579,8 @@ mod tests { let template = FundingTemplate::new( Some(shared_input(100_000)), Some(min_rbf_feerate), - Some(PriorContribution::new(prior, Amount::MAX)), + Some(prior), + Amount::MAX, ); let wallet = SingleUtxoWallet { @@ -3605,8 +3606,12 @@ mod tests { let feerate = FeeRate::from_sat_per_kwu(2025); let withdrawal = funding_output_sats(20_000); - let template = - FundingTemplate::new(Some(shared_input(100_000)), Some(min_rbf_feerate), None); + let template = FundingTemplate::new( + Some(shared_input(100_000)), + Some(min_rbf_feerate), + None, + Amount::MAX, + ); let contribution = template.splice_out(vec![withdrawal.clone()], feerate, FeeRate::MAX).unwrap();