Support manually selecting inputs consuming their entire value
What changed, and why it matters
This commit adds a new way to fund or top up a Lightning channel by letting the user explicitly pick which Bitcoin inputs (UTXOs) to use, with each input's full value going into the channel. It is a feature addition, not a fix for a known vulnerability. The change introduces careful checks to prevent mixing this manual-input mode with automatic coin selection, and it updates fee and balance accounting to handle full-input consumption safely. There is no evidence in the commit or supplied references that this resolves a security incident or was reported by an outside researcher.
Review as a normal feature addition. Verify that manual-input mode cannot be combined with coin-selected mode, that fee and balance arithmetic in for_acceptor_at_feerate does not underflow or misreport available buffer, and that the new input_mode serialization is backward-compatible. No urgent security patch is indicated by the available evidence.
Security signals we found
New API surface for manual input selection with mode-isolation checks
Fee-buffer arithmetic changed for manually-selected inputs in for_acceptor_at_feerate
FundingContribution serialization extended with optional input_mode TLV field
Amount/balance checks added for insufficient manual inputs and MAX_MONEY
No security-relevant disclosure or incident references present in commit or supplied materials
Evidence from the diff
The patch extends the funding/splicing builder in rust-lightning with a ‘manually selected inputs’ mode. A new FundingInputs::ManuallySelected variant and FundingInputMode enum track whether a contribution uses automatic coin selection or explicit UTXOs. New public methods (add_input, add_inputs, remove_input, splice_in_inputs) allow callers to supply UTXOs that are fully consumed without a change output. The implementation enforces mutual exclusion between coin-selected and manually-selected modes, propagates an input_mode field through FundingContribution serialization, and adjusts fee-buffer logic in for_acceptor_at_feerate so that manual inputs can add to or offset splice-out costs. Existing call sites that used add_value now unwrap its new Result return type. Tests cover no-change contributions, mode incompatibility, insufficient manual inputs, MAX_MONEY validation, and RBF amendment behavior.
Changed components
lightning/src/ln/funding.rslightning/src/ln/splicing_tests.rsInspect captured patch +983 / −174
diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs
index 3a0b4fb..e31f765 100644
--- a/lightning/src/ln/funding.rs
+++ b/lightning/src/ln/funding.rs
@@ -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,13 +341,15 @@ 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<W: CoinSelectionSource + MaybeSend>(
self, value_added: Amount, min_feerate: FeeRate, max_feerate: FeeRate, wallet: W,
) -> Result<FundingContribution, FundingContributionError> {
self.with_prior_contribution(min_feerate, max_feerate)
.with_coin_selection_source(wallet)
- .add_value(value_added)
+ .add_value(value_added)?
.build()
.await
}
@@ -350,16 +357,40 @@ 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<W: CoinSelectionSourceSync>(
self, value_added: Amount, min_feerate: FeeRate, max_feerate: FeeRate, wallet: W,
) -> Result<FundingContribution, FundingContributionError> {
self.with_prior_contribution(min_feerate, max_feerate)
.with_coin_selection_source_sync(wallet)
- .add_value(value_added)
+ .add_value(value_added)?
.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<FundingTxInput>, min_feerate: FeeRate, max_feerate: FeeRate,
+ ) -> Result<FundingContribution, FundingContributionError> {
+ 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
@@ -527,25 +558,70 @@ fn validate_inputs(inputs: &[FundingTxInput]) -> Result<(), FundingContributionE
Ok(())
}
-/// Describes how an amended contribution should source its wallet-backed inputs.
+/// Describes how a contribution request should source its wallet-backed inputs.
+#[derive(Debug, Clone, PartialEq, Eq)]
enum FundingInputs {
- None,
/// Reuses the contribution's existing inputs while targeting at least `value_added` added to
/// the channel after fees. If dropping the change output leaves surplus value, it remains in
/// the channel contribution.
- CoinSelected {
- value_added: Amount,
- },
+ 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: Vec<FundingTxInput> },
}
+impl FundingInputs {
+ fn mode(&self) -> FundingInputMode {
+ match self {
+ FundingInputs::CoinSelected { .. } => FundingInputMode::CoinSelected,
+ FundingInputs::ManuallySelected { .. } => FundingInputMode::ManuallySelected,
+ }
+ }
+
+ fn is_empty(&self) -> bool {
+ match self {
+ FundingInputs::CoinSelected { value_added } => *value_added == Amount::ZERO,
+ FundingInputs::ManuallySelected { inputs } => inputs.is_empty(),
+ }
+ }
+
+ fn value_added(&self) -> Amount {
+ match self {
+ FundingInputs::CoinSelected { value_added } => *value_added,
+ FundingInputs::ManuallySelected { .. } => Amount::ZERO,
+ }
+ }
+
+ fn manually_selected_inputs(&self) -> &[FundingTxInput] {
+ match self {
+ FundingInputs::ManuallySelected { inputs } => inputs,
+ FundingInputs::CoinSelected { .. } => &[],
+ }
+ }
+}
+
+#[derive(Copy, Clone, Debug, Hash, PartialEq, Eq)]
+enum FundingInputMode {
+ CoinSelected,
+ ManuallySelected,
+}
+
+impl_writeable_tlv_based_enum!(FundingInputMode,
+ (1, CoinSelected) => {},
+ (3, ManuallySelected) => {}
+);
+
/// The components of a funding transaction contributed by one party.
#[derive(Debug, Clone, Hash, 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<FundingTxInput>,
/// The outputs to include in the funding transaction.
@@ -565,6 +641,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 and is set accordingly based on the first
+ /// `add_value` or `add_input` call on the builder.
+ input_mode: Option<FundingInputMode>,
}
impl_writeable_tlv_based!(FundingContribution, {
@@ -575,6 +657,7 @@ impl_writeable_tlv_based!(FundingContribution, {
(9, feerate, required),
(11, max_feerate, required),
(13, is_splice, required),
+ (15, input_mode, option),
});
impl FundingContribution {
@@ -593,11 +676,13 @@ impl FundingContribution {
.map(|output| output.script_pubkey.as_script())
}
- /// 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::<Amount>();
- let total_output_value = self.outputs.iter().map(|output| output.value).sum::<Amount>();
+ 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))
@@ -658,84 +743,91 @@ 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: Option<FundingInputs>, outputs: &[TxOut], target_feerate: FeeRate,
max_feerate: FeeRate, spliceable_balance: Amount,
) -> Option<Self> {
// NOTE: The contribution returned is not guaranteed to be valid. We defer doing so until
// `compute_feerate_adjustment`.
- let adjust_for_inputs_and_outputs =
- |contribution: Self, inputs: FundingInputs, outputs: &[TxOut]| -> Option<Self> {
- let (target_value_added, inputs) = match inputs {
- FundingInputs::None => (None, Vec::new()),
- FundingInputs::CoinSelected { value_added } => {
- (Some(value_added), contribution.inputs)
- },
- };
-
- if inputs.is_empty() && target_value_added.unwrap_or(Amount::ZERO) != Amount::ZERO {
- // Prior contribution didn't have any inputs, but now we need some.
- return None;
- }
+ let adjust_for_inputs_and_outputs = |contribution: Self,
+ inputs: Option<FundingInputs>,
+ outputs: &[TxOut]|
+ -> Option<Self> {
+ let input_mode = inputs.as_ref().map(FundingInputs::mode);
+ let (target_value_added, inputs) = match inputs {
+ None => (None, Vec::new()),
+ Some(FundingInputs::CoinSelected { value_added }) => {
+ // We track the prior contribution's inputs here to see if they can cover the
+ // new `value_added` without running coin selection.
+ (Some(value_added), contribution.inputs)
+ },
+ Some(FundingInputs::ManuallySelected { inputs }) => (None, inputs),
+ };
- // 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, spliceable_balance, true)
.ok()?;
@@ -847,7 +939,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(
@@ -893,18 +987,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 > spliceable_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() {
+ spliceable_balance
+ .checked_sub(net_value_without_fee.unsigned_abs())
+ .unwrap_or(Amount::ZERO)
+ } else {
+ spliceable_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: spliceable_balance
- .checked_sub(value_removed)
- .unwrap_or(Amount::ZERO),
+ source: "channel balance",
+ available: fee_buffer,
required: target_fee,
});
}
@@ -1051,7 +1149,7 @@ struct FundingBuilderInner<State> {
shared_input: Option<Input>,
min_rbf_feerate: Option<FeeRate>,
prior_contribution: Option<PriorContribution>,
- value_added: Amount,
+ funding_inputs: Option<FundingInputs>,
outputs: Vec<TxOut>,
feerate: FeeRate,
max_feerate: FeeRate,
@@ -1060,43 +1158,62 @@ struct FundingBuilderInner<State> {
/// A builder for composing or amending a [`FundingContribution`].
///
-/// The builder tracks a requested amount to add to the channel together with any explicit
-/// withdrawal outputs. Building without an attached wallet only succeeds when the request can be
-/// satisfied by reusing or amending a prior contribution, or by constructing a pure splice-out
-/// that pays fees from the channel balance.
+/// 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<NoCoinSelectionSource>);
/// A [`FundingBuilder`] with an attached asynchronous [`CoinSelectionSource`].
///
/// Created by [`FundingBuilder::with_coin_selection_source`]. The attached wallet is only used
-/// if the request cannot be satisfied by reusing a prior contribution or by building a pure
-/// splice-out directly.
+/// 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<W>(FundingBuilderInner<AsyncCoinSelectionSource<W>>);
/// A [`FundingBuilder`] with an attached synchronous [`CoinSelectionSourceSync`].
///
/// Created by [`FundingBuilder::with_coin_selection_source_sync`]. The attached wallet is only
-/// used if the request cannot be satisfied by reusing a prior contribution or by building a pure
-/// splice-out directly.
+/// 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<W>(FundingBuilderInner<SyncCoinSelectionSource<W>>);
impl<State> FundingBuilderInner<State> {
fn request_matches_prior(&self, prior_contribution: &FundingContribution) -> bool {
- self.value_added == prior_contribution.value_added()
- && self.outputs == prior_contribution.outputs
+ let request_matches_prior_inputs =
+ match (self.funding_inputs.as_ref(), prior_contribution.input_mode) {
+ (
+ Some(FundingInputs::ManuallySelected { inputs }),
+ Some(FundingInputMode::ManuallySelected),
+ ) => {
+ let request_inputs = 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(FundingInputs::CoinSelected { value_added }),
+ Some(FundingInputMode::CoinSelected),
+ ) => *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,
+ &self, contribution: PriorContribution,
) -> Result<FundingContribution, FundingContributionError> {
let PriorContribution { contribution, spliceable_balance } = contribution;
+ let input_mode = self.funding_inputs.as_ref().map(FundingInputs::mode);
if self.request_matches_prior(&contribution) {
// Same request, but the feerate may have changed. Adjust the prior contribution
@@ -1107,57 +1224,87 @@ impl<State> FundingBuilderInner<State> {
adjusted.max_feerate = self.max_feerate;
adjusted
})
- .map_err(|_| FundingContributionError::MissingCoinSelectionSource);
+ .map_err(|_| {
+ if input_mode == Some(FundingInputMode::ManuallySelected) {
+ FundingContributionError::ManuallySelectedInputsInsufficient
+ } else {
+ FundingContributionError::MissingCoinSelectionSource
+ }
+ });
}
- let funding_inputs = if self.value_added != Amount::ZERO {
- FundingInputs::CoinSelected { value_added: self.value_added }
- } else {
- FundingInputs::None
- };
return contribution
.amend_without_coin_selection(
- funding_inputs,
+ self.funding_inputs.clone(),
&self.outputs,
self.feerate,
self.max_feerate,
spliceable_balance,
)
- .ok_or_else(|| FundingContributionError::MissingCoinSelectionSource);
+ .ok_or_else(|| {
+ if input_mode == Some(FundingInputMode::ManuallySelected) {
+ 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,
+ &self,
) -> Result<FundingContribution, FundingContributionError> {
- if let Some(contribution) = self.prior_contribution.take() {
- return self.build_from_prior_contribution(contribution);
+ if let Some(contribution) = self.prior_contribution.as_ref() {
+ return self.build_from_prior_contribution(contribution.clone());
}
- if self.value_added == Amount::ZERO {
+ let value_added =
+ self.funding_inputs.as_ref().map_or(Amount::ZERO, FundingInputs::value_added);
+ if value_added == Amount::ZERO {
+ let inputs = self
+ .funding_inputs
+ .as_ref()
+ .map_or(&[][..], FundingInputs::manually_selected_inputs);
+ let input_mode =
+ if inputs.is_empty() { None } else { Some(FundingInputMode::ManuallySelected) };
+
+ 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![],
- outputs: core::mem::take(&mut self.outputs),
+ inputs: match self.funding_inputs {
+ Some(FundingInputs::ManuallySelected { ref inputs }) => inputs.clone(),
+ None | Some(FundingInputs::CoinSelected { .. }) => Vec::new(),
+ },
+ outputs: self.outputs.clone(),
change_output: None,
feerate: self.feerate,
max_feerate: self.max_feerate,
is_splice: self.shared_input.is_some(),
+ input_mode,
});
}
@@ -1167,6 +1314,8 @@ impl<State> FundingBuilderInner<State> {
fn prepare_coin_selection_request(
&self,
) -> Result<(Vec<Input>, Vec<TxOut>), FundingContributionError> {
+ let value_added =
+ self.funding_inputs.as_ref().map_or(Amount::ZERO, FundingInputs::value_added);
let dummy_pubkey = PublicKey::from_slice(&[2; 33]).unwrap();
let shared_output = bitcoin::TxOut {
value: self
@@ -1174,7 +1323,7 @@ impl<State> FundingBuilderInner<State> {
.as_ref()
.map(|shared_input| shared_input.previous_utxo.value)
.unwrap_or(Amount::ZERO)
- .checked_add(self.value_added)
+ .checked_add(value_added)
.ok_or(FundingContributionError::InvalidSpliceValue)?,
script_pubkey: make_funding_redeemscript(&dummy_pubkey, &dummy_pubkey).to_p2wsh(),
};
@@ -1206,7 +1355,9 @@ impl<State> FundingBuilderInner<State> {
}
}
- if self.value_added == Amount::ZERO && self.outputs.is_empty() {
+ if self.funding_inputs.as_ref().map_or(true, FundingInputs::is_empty)
+ && self.outputs.is_empty()
+ {
return Err(FundingContributionError::InvalidSpliceValue);
}
@@ -1214,10 +1365,16 @@ impl<State> FundingBuilderInner<State> {
// ensure FundingContribution::net_value() arithmetic cannot overflow. With all
// amounts bounded by MAX_MONEY (~2.1e15 sat), the worst-case net_value()
// computation is -2 * MAX_MONEY (~-4.2e15), well within i64::MIN (~-9.2e18).
- if self.value_added > Amount::MAX_MONEY {
+ if self.funding_inputs.as_ref().map_or(Amount::ZERO, FundingInputs::value_added)
+ > Amount::MAX_MONEY
+ {
return Err(FundingContributionError::InvalidSpliceValue);
}
+ validate_inputs(
+ self.funding_inputs.as_ref().map_or(&[][..], FundingInputs::manually_selected_inputs),
+ )?;
+
let mut value_removed = Amount::ZERO;
for output in self.outputs.iter() {
value_removed = match value_removed.checked_add(output.value) {
@@ -1233,19 +1390,29 @@ impl<State> FundingBuilderInner<State> {
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 (funding_inputs, outputs) = match prior_contribution.as_ref() {
Some(prior) => {
- let outputs = prior.contribution.outputs.clone();
- (prior.contribution.value_added(), outputs)
+ let funding_inputs = match prior.contribution.input_mode {
+ Some(FundingInputMode::ManuallySelected) => {
+ Some(FundingInputs::ManuallySelected {
+ inputs: prior.contribution.inputs.clone(),
+ })
+ },
+ Some(FundingInputMode::CoinSelected) => Some(FundingInputs::CoinSelected {
+ value_added: prior.contribution.value_added(),
+ }),
+ None => None,
+ };
+ (funding_inputs, prior.contribution.outputs.clone())
},
- None => (Amount::ZERO, Vec::new()),
+ None => (None, Vec::new()),
};
FundingBuilder(FundingBuilderInner {
shared_input,
min_rbf_feerate,
prior_contribution,
- value_added,
+ funding_inputs,
outputs,
feerate,
max_feerate,
@@ -1256,7 +1423,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<W: CoinSelectionSource + MaybeSend>(
self, wallet: W,
) -> AsyncFundingBuilder<W> {
@@ -1266,13 +1434,58 @@ 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<W: CoinSelectionSourceSync>(
self, wallet: W,
) -> SyncFundingBuilder<W> {
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.
+ ///
+ /// Returns [`FundingContributionError::InvalidSpliceValue`] if the builder already has a
+ /// coin-selected value request.
+ pub fn add_input(self, input: FundingTxInput) -> Result<Self, FundingContributionError> {
+ self.0.add_input_inner(input).map(FundingBuilder)
+ }
+
+ /// 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.
+ ///
+ /// Returns [`FundingContributionError::InvalidSpliceValue`] if the builder already has a
+ /// coin-selected value request.
+ pub fn add_inputs(self, inputs: Vec<FundingTxInput>) -> Result<Self, FundingContributionError> {
+ self.0.add_inputs_inner(inputs).map(FundingBuilder)
+ }
+
+ /// Removes all manually selected inputs whose outpoint matches `outpoint`.
+ ///
+ /// Returns [`FundingContributionError::InvalidSpliceValue`] if the builder already has a
+ /// coin-selected value request.
+ pub fn remove_input(self, outpoint: &OutPoint) -> Result<Self, FundingContributionError> {
+ self.0.remove_input_inner(outpoint).map(FundingBuilder)
+ }
+
/// Adds a withdrawal output to the request.
///
/// `output` is appended to the current set of explicit outputs. If the builder was seeded from
@@ -1302,12 +1515,13 @@ 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.
- pub fn build(mut self) -> Result<FundingContribution, FundingContributionError> {
+ /// inputs are needed, or [`FundingContributionError::ManuallySelectedInputsInsufficient`] if
+ /// the manually selected inputs cannot satisfy the request.
+ pub fn build(self) -> Result<FundingContribution, FundingContributionError> {
self.0.build_without_coin_selection()
}
}
@@ -1318,7 +1532,7 @@ impl<State> FundingBuilderInner<State> {
shared_input: self.shared_input,
min_rbf_feerate: self.min_rbf_feerate,
prior_contribution: self.prior_contribution,
- value_added: self.value_added,
+ funding_inputs: self.funding_inputs,
outputs: self.outputs,
feerate: self.feerate,
max_feerate: self.max_feerate,
@@ -1326,16 +1540,73 @@ impl<State> FundingBuilderInner<State> {
}
}
- fn add_value_inner(mut self, value: Amount) -> Self {
- self.value_added =
- Amount::from_sat(self.value_added.to_sat().saturating_add(value.to_sat()));
- self
+ fn add_value_inner(mut self, value: Amount) -> Result<Self, FundingContributionError> {
+ match &mut self.funding_inputs {
+ None => self.funding_inputs = Some(FundingInputs::CoinSelected { value_added: value }),
+ Some(FundingInputs::CoinSelected { value_added }) => {
+ *value_added =
+ Amount::from_sat(value_added.to_sat().saturating_add(value.to_sat()));
+ },
+ Some(FundingInputs::ManuallySelected { .. }) => {
+ return Err(FundingContributionError::InvalidSpliceValue);
+ },
+ }
+ Ok(self)
}
- fn remove_value_inner(mut self, value: Amount) -> Self {
- self.value_added =
- Amount::from_sat(self.value_added.to_sat().saturating_sub(value.to_sat()));
- self
+ fn remove_value_inner(mut self, value: Amount) -> Result<Self, FundingContributionError> {
+ match &mut self.funding_inputs {
+ None => {},
+ Some(FundingInputs::CoinSelected { value_added }) => {
+ *value_added =
+ Amount::from_sat(value_added.to_sat().saturating_sub(value.to_sat()));
+ },
+ Some(FundingInputs::ManuallySelected { .. }) => {
+ return Err(FundingContributionError::InvalidSpliceValue);
+ },
+ }
+ Ok(self)
+ }
+
+ fn add_input_inner(mut self, input: FundingTxInput) -> Result<Self, FundingContributionError> {
+ match &mut self.funding_inputs {
+ None => {
+ self.funding_inputs = Some(FundingInputs::ManuallySelected { inputs: vec![input] })
+ },
+ Some(FundingInputs::ManuallySelected { inputs }) => inputs.push(input),
+ Some(FundingInputs::CoinSelected { .. }) => {
+ return Err(FundingContributionError::InvalidSpliceValue);
+ },
+ }
+ Ok(self)
+ }
+
+ fn add_inputs_inner(
+ mut self, inputs: Vec<FundingTxInput>,
+ ) -> Result<Self, FundingContributionError> {
+ match &mut self.funding_inputs {
+ None => self.funding_inputs = Some(FundingInputs::ManuallySelected { inputs }),
+ Some(FundingInputs::ManuallySelected { inputs: existing_inputs }) => {
+ existing_inputs.extend(inputs)
+ },
+ Some(FundingInputs::CoinSelected { .. }) => {
+ return Err(FundingContributionError::InvalidSpliceValue);
+ },
+ }
+ Ok(self)
+ }
+
+ fn remove_input_inner(mut self, outpoint: &OutPoint) -> Result<Self, FundingContributionError> {
+ match &mut self.funding_inputs {
+ None => {},
+ Some(FundingInputs::ManuallySelected { inputs }) => {
+ inputs.retain(|input| input.utxo.outpoint != *outpoint);
+ },
+ Some(FundingInputs::CoinSelected { .. }) => {
+ return Err(FundingContributionError::InvalidSpliceValue);
+ },
+ }
+ Ok(self)
}
fn add_output_inner(mut self, output: TxOut) -> Self {
@@ -1357,9 +1628,11 @@ impl<State> FundingBuilderInner<State> {
/// 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,
+ &self,
) -> Result<FundingContribution, FundingContributionError> {
self.validate_contribution_parameters()?;
self.try_build_without_coin_selection()
@@ -1399,8 +1672,11 @@ impl<W> AsyncFundingBuilder<W> {
/// prior contribution, this increases that prior contribution's current amount added to the
/// channel. If the updated request cannot be satisfied in-place, [`AsyncFundingBuilder::build`]
/// may re-run coin selection and return a contribution with a different input set.
- pub fn add_value(self, value: Amount) -> Self {
- AsyncFundingBuilder(self.0.add_value_inner(value))
+ ///
+ /// Returns [`FundingContributionError::InvalidSpliceValue`] if the builder already has manually
+ /// selected inputs.
+ pub fn add_value(self, value: Amount) -> Result<Self, FundingContributionError> {
+ self.0.add_value_inner(value).map(AsyncFundingBuilder)
}
/// Decreases the requested amount to add to the channel.
@@ -1410,8 +1686,11 @@ impl<W> AsyncFundingBuilder<W> {
/// amount added to the channel. If the updated request cannot be satisfied in-place,
/// [`AsyncFundingBuilder::build`] may re-run coin selection and return a contribution with a
/// different input set.
- pub fn remove_value(self, value: Amount) -> Self {
- AsyncFundingBuilder(self.0.remove_value_inner(value))
+ ///
+ /// Returns [`FundingContributionError::InvalidSpliceValue`] if the builder already has manually
+ /// selected inputs.
+ pub fn remove_value(self, value: Amount) -> Result<Self, FundingContributionError> {
+ self.0.remove_value_inner(value).map(AsyncFundingBuilder)
}
}
@@ -1419,9 +1698,10 @@ impl<W: CoinSelectionSource + MaybeSend> AsyncFundingBuilder<W> {
/// Builds a [`FundingContribution`], using the attached asynchronous wallet only when needed.
///
/// If the request can be satisfied by reusing or amending a prior contribution, or by building
- /// a pure splice-out directly, the attached wallet is ignored.
+ /// a pure splice-out directly, or by using only manually selected inputs, the attached wallet is
+ /// ignored.
pub async fn build(self) -> Result<FundingContribution, FundingContributionError> {
- let mut inner = self.0;
+ let inner = self.0;
match inner.build_without_coin_selection() {
Err(FundingContributionError::MissingCoinSelectionSource) => {},
other => return other,
@@ -1462,6 +1742,7 @@ impl<W: CoinSelectionSource + MaybeSend> AsyncFundingBuilder<W> {
feerate: inner.feerate,
max_feerate: inner.max_feerate,
is_splice,
+ input_mode: Some(FundingInputMode::CoinSelected),
});
}
}
@@ -1499,8 +1780,11 @@ impl<W> SyncFundingBuilder<W> {
/// prior contribution, this increases that prior contribution's current amount added to the
/// channel. If the updated request cannot be satisfied in-place, [`SyncFundingBuilder::build`]
/// may re-run coin selection and return a contribution with a different input set.
- pub fn add_value(self, value: Amount) -> Self {
- SyncFundingBuilder(self.0.add_value_inner(value))
+ ///
+ /// Returns [`FundingContributionError::InvalidSpliceValue`] if the builder already has manually
+ /// selected inputs.
+ pub fn add_value(self, value: Amount) -> Result<Self, FundingContributionError> {
+ self.0.add_value_inner(value).map(SyncFundingBuilder)
}
/// Decreases the requested amount to add to the channel.
@@ -1510,8 +1794,11 @@ impl<W> SyncFundingBuilder<W> {
/// amount added to the channel. If the updated request cannot be satisfied in-place,
/// [`SyncFundingBuilder::build`] may re-run coin selection and return a contribution with a
/// different input set.
- pub fn remove_value(self, value: Amount) -> Self {
- SyncFundingBuilder(self.0.remove_value_inner(value))
+ ///
+ /// Returns [`FundingContributionError::InvalidSpliceValue`] if the builder already has manually
+ /// selected inputs.
+ pub fn remove_value(self, value: Amount) -> Result<Self, FundingContributionError> {
+ self.0.remove_value_inner(value).map(SyncFundingBuilder)
}
}
@@ -1519,9 +1806,10 @@ impl<W: CoinSelectionSourceSync> SyncFundingBuilder<W> {
/// Builds a [`FundingContribution`], using the attached synchronous wallet only when needed.
///
/// If the request can be satisfied by reusing or amending a prior contribution, or by building
- /// a pure splice-out directly, the attached wallet is ignored.
+ /// a pure splice-out directly, or by using only manually selected inputs, the attached wallet is
+ /// ignored.
pub fn build(self) -> Result<FundingContribution, FundingContributionError> {
- let mut inner = self.0;
+ let inner = self.0;
match inner.build_without_coin_selection() {
Err(FundingContributionError::MissingCoinSelectionSource) => {},
other => return other,
@@ -1561,6 +1849,7 @@ impl<W: CoinSelectionSourceSync> SyncFundingBuilder<W> {
feerate: inner.feerate,
max_feerate: inner.max_feerate,
is_splice,
+ input_mode: Some(FundingInputMode::CoinSelected),
});
}
}
@@ -1569,7 +1858,8 @@ impl<W: CoinSelectionSourceSync> SyncFundingBuilder<W> {
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};
@@ -1747,7 +2037,7 @@ mod tests {
let feerate = FeeRate::from_sat_per_kwu(2000);
let builder =
FundingBuilder::new(FundingTemplate::new(None, None, None), feerate, FeeRate::MAX);
- let builder = FundingBuilder(builder.0.add_value_inner(Amount::from_sat(25_000)));
+ let builder = FundingBuilder(builder.0.add_value_inner(Amount::from_sat(25_000)).unwrap());
assert!(matches!(
builder.build(),
@@ -1775,6 +2065,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);
@@ -1789,9 +2080,10 @@ mod tests {
);
let builder =
- FundingTemplate::new(None, None, Some(PriorContribution::new(prior, Amount::MAX)))
+ FundingTemplate::new(None, None, Some(PriorContribution::new(prior, Amount::ZERO)))
.with_prior_contribution(feerate, FeeRate::MAX);
- let contribution = FundingBuilder(builder.0.add_value_inner(delta)).build().unwrap();
+ let contribution =
+ FundingBuilder(builder.0.add_value_inner(delta).unwrap()).build().unwrap();
assert!(contribution.change_output.is_none());
assert_eq!(contribution.inputs, inputs);
@@ -1830,16 +2122,40 @@ mod tests {
#[test]
fn test_funding_builder_add_and_remove_value_update_request() {
let feerate = FeeRate::from_sat_per_kwu(2000);
- let builder =
+ let value_added = Amount::from_sat(15_000);
+ let input_template = funding_input_sats(1);
+ let estimated_fee = estimate_transaction_fee(
+ std::slice::from_ref(&input_template),
+ &[],
+ None,
+ true,
+ false,
+ feerate,
+ );
+ let selected_amount = value_added + estimated_fee;
+ let input = funding_input_sats(selected_amount.to_sat());
+ let wallet = MustPayToWallet {
+ utxo: input.clone(),
+ change_output: None,
+ expected_must_pay_to_values: vec![value_added],
+ };
+
+ let contribution =
FundingBuilder::new(FundingTemplate::new(None, None, None), feerate, FeeRate::MAX)
- .with_coin_selection_source_sync(UnreachableWallet)
+ .with_coin_selection_source_sync(wallet)
.add_value(Amount::from_sat(20_000))
+ .unwrap()
.add_value(Amount::from_sat(5_000))
- .remove_value(Amount::from_sat(10_000));
+ .unwrap()
+ .remove_value(Amount::from_sat(10_000))
+ .unwrap()
+ .build()
+ .unwrap();
- let (_, must_pay_to) = builder.0.prepare_coin_selection_request().unwrap();
- assert_eq!(must_pay_to.len(), 1);
- assert_eq!(must_pay_to[0].value, Amount::from_sat(15_000));
+ assert_eq!(contribution.inputs, vec![input]);
+ assert!(contribution.outputs.is_empty());
+ assert!(contribution.change_output.is_none());
+ assert_eq!(contribution.value_added(), value_added);
}
#[test]
@@ -1871,6 +2187,7 @@ mod tests {
FundingBuilder::new(FundingTemplate::new(None, None, None), feerate, FeeRate::MAX)
.with_coin_selection_source_sync(wallet)
.add_value(value_added)
+ .unwrap()
.add_output(output.clone())
.build()
.unwrap();
@@ -1888,7 +2205,9 @@ mod tests {
FundingBuilder::new(FundingTemplate::new(None, None, None), feerate, FeeRate::MAX)
.with_coin_selection_source_sync(UnreachableWallet)
.add_value(Amount::from_sat(10_000))
+ .unwrap()
.remove_value(Amount::from_sat(15_000))
+ .unwrap()
.add_output(output.clone())
.build()
.unwrap();
@@ -1899,6 +2218,399 @@ 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())
+ .unwrap()
+ .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::ManuallySelected));
+ 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()])
+ .unwrap()
+ .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::ManuallySelected));
+ 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()])
+ .unwrap()
+ .remove_input(&first_input.utxo.outpoint)
+ .unwrap()
+ .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::ManuallySelected));
+ 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::ManuallySelected));
+ 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::ManuallySelected),
+ };
+
+ 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::ManuallySelected));
+ }
+
+ #[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))
+ .unwrap();
+ 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))
+ .unwrap();
+ let result = builder.clone().0.add_value_inner(Amount::from_sat(1_000));
+ assert!(matches!(result, Err(FundingContributionError::InvalidSpliceValue),));
+
+ let builder =
+ SyncFundingBuilder(builder.0.with_state(SyncCoinSelectionSource(UnreachableWallet)));
+ let result = builder.remove_value(Amount::from_sat(1_000));
+ assert!(matches!(result, 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_input = funding_input_sats(100_000);
+ let prior_outpoint = prior_input.utxo.outpoint;
+ let prior = FundingContribution {
+ estimated_fee: Amount::from_sat(1_000),
+ inputs: vec![prior_input],
+ 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::ZERO)))
+ .with_prior_contribution(feerate, FeeRate::MAX);
+
+ assert!(matches!(
+ builder.clone().add_input(funding_input_sats(50_000)),
+ Err(FundingContributionError::InvalidSpliceValue),
+ ));
+ assert!(matches!(
+ builder.remove_input(&prior_outpoint),
+ 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)
+ .unwrap();
+
+ 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::ManuallySelected),
+ };
+
+ 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::ManuallySelected));
+ }
+
+ #[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::ManuallySelected),
+ };
+
+ 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::ManuallySelected),
+ };
+
+ 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::ManuallySelected),
+ };
+
+ 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);
@@ -1949,6 +2661,7 @@ mod tests {
.without_prior_contribution(feerate, feerate)
.with_coin_selection_source_sync(UnreachableWallet)
.add_value(over_max)
+ .unwrap()
.add_outputs(vec![funding_output_sats(1_000)])
.build(),
Err(FundingContributionError::InvalidSpliceValue),
@@ -1961,6 +2674,7 @@ mod tests {
.without_prior_contribution(feerate, feerate)
.with_coin_selection_source_sync(UnreachableWallet)
.add_value(Amount::from_sat(1_000))
+ .unwrap()
.add_outputs(vec![
funding_output_sats(half_over.to_sat()),
funding_output_sats(half_over.to_sat()),
@@ -2021,6 +2735,7 @@ mod tests {
.with_prior_contribution(feerate, feerate)
.with_coin_selection_source_sync(wallet)
.add_value(Amount::from_sat(10_000))
+ .unwrap()
.build(),
Err(FundingContributionError::PrevTxTooLarge),
));
@@ -2049,6 +2764,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();
@@ -2086,6 +2802,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);
@@ -2126,6 +2843,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();
@@ -2161,6 +2879,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);
@@ -2186,6 +2905,7 @@ mod tests {
feerate: original_feerate,
max_feerate: FeeRate::MAX,
is_splice: true,
+ input_mode: Some(FundingInputMode::CoinSelected),
};
let contribution =
@@ -2215,6 +2935,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.
@@ -2244,6 +2965,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
@@ -2276,6 +2998,7 @@ mod tests {
feerate: original_feerate,
max_feerate: FeeRate::MAX,
is_splice: true,
+ input_mode: Some(FundingInputMode::CoinSelected),
};
let net_at_feerate =
@@ -2311,6 +3034,7 @@ mod tests {
feerate: original_feerate,
max_feerate: FeeRate::MAX,
is_splice: true,
+ input_mode: Some(FundingInputMode::CoinSelected),
};
let net_before = contribution.net_value();
@@ -2344,6 +3068,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);
@@ -2371,6 +3096,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);
@@ -2402,6 +3128,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);
@@ -2436,6 +3163,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);
@@ -2478,6 +3206,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);
@@ -2510,6 +3239,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);
@@ -2548,6 +3278,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);
@@ -2584,6 +3315,7 @@ mod tests {
feerate,
max_feerate: FeeRate::MAX,
is_splice: true,
+ input_mode: Some(FundingInputMode::CoinSelected),
};
// target == min feerate, so FeeRateTooLow check passes.
@@ -2611,6 +3343,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);
@@ -2635,6 +3368,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.
@@ -2661,6 +3395,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.
@@ -2691,6 +3426,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.
@@ -2720,6 +3456,7 @@ mod tests {
feerate: original_feerate,
max_feerate: FeeRate::MAX,
is_splice: true,
+ input_mode: Some(FundingInputMode::CoinSelected),
};
let acceptor =
@@ -2754,6 +3491,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).
@@ -2790,6 +3528,7 @@ mod tests {
feerate: prior_feerate,
max_feerate: FeeRate::MAX,
is_splice: true,
+ input_mode: Some(FundingInputMode::CoinSelected),
};
let template = FundingTemplate::new(
@@ -2823,6 +3562,7 @@ mod tests {
feerate: prior_feerate,
max_feerate: FeeRate::MAX,
is_splice: true,
+ input_mode: Some(FundingInputMode::CoinSelected),
};
let template = FundingTemplate::new(
@@ -2851,6 +3591,7 @@ mod tests {
feerate: prior_feerate,
max_feerate: FeeRate::MAX,
is_splice: true,
+ input_mode: Some(FundingInputMode::CoinSelected),
};
let template = FundingTemplate::new(
@@ -2883,6 +3624,7 @@ mod tests {
feerate: prior_feerate,
max_feerate: FeeRate::MAX,
is_splice: true,
+ input_mode: Some(FundingInputMode::CoinSelected),
};
let template = FundingTemplate::new(
@@ -2949,6 +3691,7 @@ mod tests {
feerate: prior_feerate,
max_feerate: prior_feerate,
is_splice: true,
+ input_mode: Some(FundingInputMode::CoinSelected),
};
let template = FundingTemplate::new(
@@ -2990,6 +3733,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 6bd5d52..f4843f7 100644
--- a/lightning/src/ln/splicing_tests.rs
+++ b/lightning/src/ln/splicing_tests.rs
@@ -44,6 +44,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,
@@ -298,6 +299,7 @@ pub fn do_initiate_splice_in_and_out<'a, 'b, 'c, 'd>(
.without_prior_contribution(feerate, FeeRate::MAX)
.with_coin_selection_source_sync(&wallet)
.add_value(value_added)
+ .unwrap()
.add_outputs(outputs)
.build()
.unwrap();
@@ -4323,6 +4325,7 @@ fn test_funding_contributed_splice_already_pending() {
.with_prior_contribution(feerate, FeeRate::MAX)
.with_coin_selection_source_sync(&wallet)
.add_value(splice_in_amount)
+ .unwrap()
.add_output(first_splice_out.clone())
.build()
.unwrap();
@@ -4345,6 +4348,7 @@ fn test_funding_contributed_splice_already_pending() {
.without_prior_contribution(feerate, FeeRate::MAX)
.with_coin_selection_source_sync(&wallet)
.add_value(splice_in_amount)
+ .unwrap()
.add_output(second_splice_out.clone())
.build()
.unwrap();
@@ -4495,6 +4499,7 @@ fn do_test_funding_contributed_active_funding_negotiation(state: u8) {
.without_prior_contribution(feerate, FeeRate::MAX)
.with_coin_selection_source_sync(&wallet)
.add_value(splice_in_amount)
+ .unwrap()
.add_outputs(vec![splice_out_output.clone()])
.build()
.unwrap();
@@ -5204,6 +5209,7 @@ fn test_splice_rbf_discard_unique_contribution() {
.without_prior_contribution(rbf_feerate, FeeRate::MAX)
.with_coin_selection_source_sync(&wallet)
.add_value(added_value)
+ .unwrap()
.build()
.unwrap();
nodes[0]
@@ -5775,6 +5781,7 @@ fn test_splice_rbf_stfu_after_splice_locked() {
.without_prior_contribution(rbf_feerate, FeeRate::MAX)
.with_coin_selection_source_sync(&wallet)
.add_value(added_value)
+ .unwrap()
.build()
.unwrap();
nodes[0]
@@ -6949,6 +6956,7 @@ fn test_splice_rbf_amends_prior_net_positive_contribution_request() {
.with_prior_contribution(rbf_feerate, FeeRate::MAX)
.with_coin_selection_source_sync(&wallet)
.remove_value(half_added_value)
+ .unwrap()
.build()
.unwrap();
let (inputs_2, _) = contribution_2.clone().into_contributed_inputs_and_outputs();
@@ -7032,6 +7040,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, replaced_txid: Txid| {
nodes[0]
@@ -7085,21 +7098,72 @@ 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()])
+ .unwrap()
+ .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, splice_tx_2.compute_txid());
+ let splice_tx_3 = run_rbf_round(contribution_3.clone(), splice_tx_2.compute_txid());
+
+ 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())
+ .unwrap()
+ .remove_input(&manual_input_0.utxo.outpoint)
+ .unwrap()
+ .remove_input(&manual_input_1.utxo.outpoint)
+ .unwrap()
+ .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(), splice_tx_3.compute_txid());
+
+ 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, splice_tx_4.compute_txid());
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(),
+ ],
);
}
@@ -8088,6 +8152,7 @@ fn test_splice_rbf_rejects_own_low_feerate_after_several_attempts() {
.without_prior_contribution(rbf_feerate, FeeRate::MAX)
.with_coin_selection_source_sync(&wallet)
.add_value(added_value)
+ .unwrap()
.build()
.unwrap();
let result = nodes[0].node.funding_contributed(&channel_id, &node_id_1, contribution, None);
Why this scored 30/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.