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 20366fe772a..e9ab883bb8d 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. @@ -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, @@ -147,6 +148,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, } @@ -161,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") @@ -172,6 +175,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") }, @@ -179,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 @@ -255,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. @@ -291,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. @@ -336,7 +322,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 +338,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 +349,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 +540,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 +548,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 +596,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 +612,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 +632,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 +650,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 +683,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 +863,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 +911,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, }); } @@ -1013,8 +1072,10 @@ 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, feerate: FeeRate, max_feerate: FeeRate, @@ -1023,60 +1084,95 @@ 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, + &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 }) - .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( @@ -1084,19 +1180,27 @@ impl FundingBuilderInner { &self.outputs, self.feerate, self.max_feerate, - holder_balance, + self.spliceable_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,23 +1209,40 @@ impl FundingBuilderInner { } if self.value_added == Amount::ZERO { + let inputs = &self.manually_selected_inputs; + let input_mode = self.request_input_mode(); + let estimated_fee = estimate_transaction_fee( - &[], + inputs, &self.outputs, None, true, self.shared_input.is_some(), self.feerate, ); - return Ok(FundingContribution { + + let contribution = 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, + }; + 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) @@ -1169,10 +1290,29 @@ 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(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 +1321,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) { @@ -1195,21 +1337,34 @@ 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 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(); - (prior.contribution.value_added(), outputs) + 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.inputs.clone(), outputs) + } else { + (prior.value_added(), Vec::new(), outputs) + } }, - None => (Amount::ZERO, Vec::new()), + None => (Amount::ZERO, Vec::new(), Vec::new()), }; FundingBuilder(FundingBuilderInner { shared_input, min_rbf_feerate, prior_contribution, + spliceable_balance, value_added, outputs, + manually_selected_inputs, feerate, max_feerate, state: NoCoinSelectionSource, @@ -1219,7 +1374,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 +1385,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 +1460,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() } @@ -1281,7 +1477,10 @@ 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, + outputs: self.outputs, feerate: self.feerate, max_feerate: self.max_feerate, @@ -1320,7 +1519,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 +1583,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 +1627,7 @@ impl AsyncFundingBuilder { feerate: inner.feerate, max_feerate: inner.max_feerate, is_splice, + input_mode: Some(FundingInputMode::CoinSelected), }); } } @@ -1482,7 +1685,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 +1728,7 @@ impl SyncFundingBuilder { feerate: inner.feerate, max_feerate: inner.max_feerate, is_splice, + input_mode: Some(FundingInputMode::CoinSelected), }); } } @@ -1532,7 +1737,8 @@ impl SyncFundingBuilder { mod tests { use super::{ estimate_transaction_fee, FeeRateAdjustmentError, FundingBuilder, FundingContribution, - FundingContributionError, FundingTemplate, FundingTxInput, PriorContribution, + FundingContributionError, FundingInputMode, FundingTemplate, FundingTxInput, + SyncCoinSelectionSource, SyncFundingBuilder, }; use crate::chain::ClaimId; use crate::util::wallet_utils::{CoinSelection, CoinSelectionSourceSync, Input}; @@ -1681,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( &[], @@ -1705,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!( @@ -1738,6 +1974,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); @@ -1751,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()); @@ -1778,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]); } @@ -1793,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); @@ -1830,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]); @@ -1847,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]); @@ -1862,6 +2110,370 @@ 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, Amount::ZERO) + .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, Amount::ZERO) + .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, Amount::ZERO) + .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, Amount::ZERO) + .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(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, Amount::ZERO) + .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, 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))); + + 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(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, Amount::ZERO) + .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(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(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); @@ -1869,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), @@ -1878,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), @@ -1888,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()), @@ -1908,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) @@ -1920,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)) @@ -1940,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 { .. }), @@ -1949,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), @@ -1980,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)) @@ -2012,6 +2624,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 +2662,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 +2703,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 +2739,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 +2765,7 @@ mod tests { feerate: original_feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; let contribution = @@ -2178,6 +2795,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 +2825,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 +2858,7 @@ mod tests { feerate: original_feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; let net_at_feerate = @@ -2274,6 +2894,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 +2928,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 +2956,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 +2988,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 +3023,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 +3066,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 +3099,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 +3138,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 +3175,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 +3203,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 +3228,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 +3255,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 +3286,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 +3315,7 @@ mod tests { feerate: original_feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; let acceptor = @@ -2716,14 +3350,11 @@ mod tests { feerate: prior_feerate, max_feerate: FeeRate::MAX, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; // 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 { .. }), @@ -2752,13 +3383,10 @@ mod tests { feerate: prior_feerate, max_feerate: FeeRate::MAX, is_splice: true, + 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); @@ -2785,13 +3413,10 @@ mod tests { feerate: prior_feerate, max_feerate: FeeRate::MAX, is_splice: true, + 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(); @@ -2813,13 +3438,10 @@ mod tests { feerate: prior_feerate, max_feerate: FeeRate::MAX, is_splice: true, + 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), @@ -2845,13 +3467,10 @@ mod tests { feerate: prior_feerate, max_feerate: FeeRate::MAX, is_splice: true, + 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), @@ -2911,12 +3530,14 @@ mod tests { feerate: prior_feerate, max_feerate: prior_feerate, is_splice: true, + input_mode: Some(FundingInputMode::CoinSelected), }; 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 { @@ -2952,12 +3573,14 @@ 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( Some(shared_input(100_000)), Some(min_rbf_feerate), - Some(PriorContribution::new(prior, Amount::MAX)), + Some(prior), + Amount::MAX, ); let wallet = SingleUtxoWallet { @@ -2983,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(); 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(), + ], ); }