diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index aca232471d6..ba3fc9077e8 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -1860,11 +1860,8 @@ pub fn do_test(data: &[u8], underlying_out: Out, anchors: bool) { 0xa0 => { let input = FundingTxInput::new_p2wpkh(coinbase_tx.clone(), 0).unwrap(); - let contribution = SpliceContribution::SpliceIn { - value: Amount::from_sat(10_000), - inputs: vec![input], - change_script: None, - }; + let contribution = + SpliceContribution::splice_in(Amount::from_sat(10_000), vec![input], None); let funding_feerate_sat_per_kw = fee_est_a.ret_val.load(atomic::Ordering::Acquire); if let Err(e) = nodes[0].splice_channel( &chan_a_id, @@ -1882,11 +1879,8 @@ pub fn do_test(data: &[u8], underlying_out: Out, anchors: bool) { }, 0xa1 => { let input = FundingTxInput::new_p2wpkh(coinbase_tx.clone(), 1).unwrap(); - let contribution = SpliceContribution::SpliceIn { - value: Amount::from_sat(10_000), - inputs: vec![input], - change_script: None, - }; + let contribution = + SpliceContribution::splice_in(Amount::from_sat(10_000), vec![input], None); let funding_feerate_sat_per_kw = fee_est_b.ret_val.load(atomic::Ordering::Acquire); if let Err(e) = nodes[1].splice_channel( &chan_a_id, @@ -1904,11 +1898,8 @@ pub fn do_test(data: &[u8], underlying_out: Out, anchors: bool) { }, 0xa2 => { let input = FundingTxInput::new_p2wpkh(coinbase_tx.clone(), 0).unwrap(); - let contribution = SpliceContribution::SpliceIn { - value: Amount::from_sat(10_000), - inputs: vec![input], - change_script: None, - }; + let contribution = + SpliceContribution::splice_in(Amount::from_sat(10_000), vec![input], None); let funding_feerate_sat_per_kw = fee_est_b.ret_val.load(atomic::Ordering::Acquire); if let Err(e) = nodes[1].splice_channel( &chan_b_id, @@ -1926,11 +1917,8 @@ pub fn do_test(data: &[u8], underlying_out: Out, anchors: bool) { }, 0xa3 => { let input = FundingTxInput::new_p2wpkh(coinbase_tx.clone(), 1).unwrap(); - let contribution = SpliceContribution::SpliceIn { - value: Amount::from_sat(10_000), - inputs: vec![input], - change_script: None, - }; + let contribution = + SpliceContribution::splice_in(Amount::from_sat(10_000), vec![input], None); let funding_feerate_sat_per_kw = fee_est_c.ret_val.load(atomic::Ordering::Acquire); if let Err(e) = nodes[2].splice_channel( &chan_b_id, @@ -1958,12 +1946,10 @@ pub fn do_test(data: &[u8], underlying_out: Out, anchors: bool) { .map(|chan| chan.outbound_capacity_msat) .unwrap(); if outbound_capacity_msat >= 20_000_000 { - let contribution = SpliceContribution::SpliceOut { - outputs: vec![TxOut { - value: Amount::from_sat(MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS), - script_pubkey: coinbase_tx.output[0].script_pubkey.clone(), - }], - }; + let contribution = SpliceContribution::splice_out(vec![TxOut { + value: Amount::from_sat(MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS), + script_pubkey: coinbase_tx.output[0].script_pubkey.clone(), + }]); let funding_feerate_sat_per_kw = fee_est_a.ret_val.load(atomic::Ordering::Acquire); if let Err(e) = nodes[0].splice_channel( @@ -1989,12 +1975,10 @@ pub fn do_test(data: &[u8], underlying_out: Out, anchors: bool) { .map(|chan| chan.outbound_capacity_msat) .unwrap(); if outbound_capacity_msat >= 20_000_000 { - let contribution = SpliceContribution::SpliceOut { - outputs: vec![TxOut { - value: Amount::from_sat(MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS), - script_pubkey: coinbase_tx.output[1].script_pubkey.clone(), - }], - }; + let contribution = SpliceContribution::splice_out(vec![TxOut { + value: Amount::from_sat(MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS), + script_pubkey: coinbase_tx.output[1].script_pubkey.clone(), + }]); let funding_feerate_sat_per_kw = fee_est_b.ret_val.load(atomic::Ordering::Acquire); if let Err(e) = nodes[1].splice_channel( @@ -2020,12 +2004,10 @@ pub fn do_test(data: &[u8], underlying_out: Out, anchors: bool) { .map(|chan| chan.outbound_capacity_msat) .unwrap(); if outbound_capacity_msat >= 20_000_000 { - let contribution = SpliceContribution::SpliceOut { - outputs: vec![TxOut { - value: Amount::from_sat(MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS), - script_pubkey: coinbase_tx.output[1].script_pubkey.clone(), - }], - }; + let contribution = SpliceContribution::splice_out(vec![TxOut { + value: Amount::from_sat(MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS), + script_pubkey: coinbase_tx.output[1].script_pubkey.clone(), + }]); let funding_feerate_sat_per_kw = fee_est_b.ret_val.load(atomic::Ordering::Acquire); if let Err(e) = nodes[1].splice_channel( @@ -2051,12 +2033,10 @@ pub fn do_test(data: &[u8], underlying_out: Out, anchors: bool) { .map(|chan| chan.outbound_capacity_msat) .unwrap(); if outbound_capacity_msat >= 20_000_000 { - let contribution = SpliceContribution::SpliceOut { - outputs: vec![TxOut { - value: Amount::from_sat(MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS), - script_pubkey: coinbase_tx.output[2].script_pubkey.clone(), - }], - }; + let contribution = SpliceContribution::splice_out(vec![TxOut { + value: Amount::from_sat(MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS), + script_pubkey: coinbase_tx.output[2].script_pubkey.clone(), + }]); let funding_feerate_sat_per_kw = fee_est_c.ret_val.load(atomic::Ordering::Acquire); if let Err(e) = nodes[2].splice_channel( diff --git a/lightning-tests/src/upgrade_downgrade_tests.rs b/lightning-tests/src/upgrade_downgrade_tests.rs index cef180fbd4e..924c6c860e1 100644 --- a/lightning-tests/src/upgrade_downgrade_tests.rs +++ b/lightning-tests/src/upgrade_downgrade_tests.rs @@ -441,12 +441,10 @@ fn do_test_0_1_htlc_forward_after_splice(fail_htlc: bool) { reconnect_b_c_args.send_announcement_sigs = (true, true); reconnect_nodes(reconnect_b_c_args); - let contribution = SpliceContribution::SpliceOut { - outputs: vec![TxOut { - value: Amount::from_sat(1_000), - script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), - }], - }; + let contribution = SpliceContribution::splice_out(vec![TxOut { + value: Amount::from_sat(1_000), + script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), + }]); let splice_tx = splice_channel(&nodes[0], &nodes[1], ChannelId(chan_id_bytes_a), contribution); for node in nodes.iter() { mine_transaction(node, &splice_tx); diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 5b4ac4c0aa5..837279419d4 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -6497,8 +6497,7 @@ fn get_v2_channel_reserve_satoshis(channel_value_satoshis: u64, dust_limit_satos fn check_splice_contribution_sufficient( contribution: &SpliceContribution, is_initiator: bool, funding_feerate: FeeRate, ) -> Result { - let contribution_amount = contribution.value(); - if contribution_amount < SignedAmount::ZERO { + if contribution.inputs().is_empty() { let estimated_fee = Amount::from_sat(estimate_v2_funding_transaction_fee( contribution.inputs(), contribution.outputs(), @@ -6507,20 +6506,26 @@ fn check_splice_contribution_sufficient( funding_feerate.to_sat_per_kwu() as u32, )); + let contribution_amount = contribution.net_value(); contribution_amount .checked_sub( estimated_fee.to_signed().expect("fees should never exceed Amount::MAX_MONEY"), ) - .ok_or(format!("Our {contribution_amount} contribution plus the fee estimate exceeds the total bitcoin supply")) + .ok_or(format!( + "{} splice-out amount plus {} fee estimate exceeds the total bitcoin supply", + contribution_amount.unsigned_abs(), + estimated_fee, + )) } else { check_v2_funding_inputs_sufficient( - contribution_amount.to_sat(), + contribution.input_value(), contribution.inputs(), + contribution.outputs(), is_initiator, true, funding_feerate.to_sat_per_kwu() as u32, ) - .map(|_| contribution_amount) + .map(|_| contribution.net_value()) } } @@ -6579,16 +6584,16 @@ fn estimate_v2_funding_transaction_fee( /// Returns estimated (partial) fees as additional information #[rustfmt::skip] fn check_v2_funding_inputs_sufficient( - contribution_amount: i64, funding_inputs: &[FundingTxInput], is_initiator: bool, - is_splice: bool, funding_feerate_sat_per_1000_weight: u32, -) -> Result { - let estimated_fee = estimate_v2_funding_transaction_fee( - funding_inputs, &[], is_initiator, is_splice, funding_feerate_sat_per_1000_weight, - ); - - let mut total_input_sats = 0u64; + contributed_input_value: Amount, funding_inputs: &[FundingTxInput], outputs: &[TxOut], + is_initiator: bool, is_splice: bool, funding_feerate_sat_per_1000_weight: u32, +) -> Result { + let estimated_fee = Amount::from_sat(estimate_v2_funding_transaction_fee( + funding_inputs, outputs, is_initiator, is_splice, funding_feerate_sat_per_1000_weight, + )); + + let mut total_input_value = Amount::ZERO; for FundingTxInput { utxo, .. } in funding_inputs.iter() { - total_input_sats = total_input_sats.checked_add(utxo.output.value.to_sat()) + total_input_value = total_input_value.checked_add(utxo.output.value) .ok_or("Sum of input values is greater than the total bitcoin supply")?; } @@ -6603,13 +6608,11 @@ fn check_v2_funding_inputs_sufficient( // TODO(splicing): refine check including the fact wether a change will be added or not. // Can be done once dual funding preparation is included. - let minimal_input_amount_needed = contribution_amount.checked_add(estimated_fee as i64) - .ok_or(format!("Our {contribution_amount} contribution plus the fee estimate exceeds the total bitcoin supply"))?; - if i64::try_from(total_input_sats).map_err(|_| "Sum of input values is greater than the total bitcoin supply")? - < minimal_input_amount_needed - { + let minimal_input_amount_needed = contributed_input_value.checked_add(estimated_fee) + .ok_or(format!("{contributed_input_value} contribution plus {estimated_fee} fee estimate exceeds the total bitcoin supply"))?; + if total_input_value < minimal_input_amount_needed { Err(format!( - "Total input amount {total_input_sats} is lower than needed for contribution {contribution_amount}, considering fees of {estimated_fee}. Need more inputs.", + "Total input amount {total_input_value} is lower than needed for splice-in contribution {contributed_input_value}, considering fees of {estimated_fee}. Need more inputs.", )) } else { Ok(estimated_fee) @@ -6675,7 +6678,7 @@ impl FundingNegotiationContext { }; // Optionally add change output - let change_value_opt = if self.our_funding_contribution > SignedAmount::ZERO { + let change_value_opt = if !self.our_funding_inputs.is_empty() { match calculate_change_output_value( &self, self.shared_funding_input.is_some(), @@ -6703,12 +6706,12 @@ impl FundingNegotiationContext { }, } }; - let mut change_output = - TxOut { value: Amount::from_sat(change_value), script_pubkey: change_script }; + let mut change_output = TxOut { value: change_value, script_pubkey: change_script }; let change_output_weight = get_output_weight(&change_output.script_pubkey).to_wu(); let change_output_fee = fee_for_weight(self.funding_feerate_sat_per_1000_weight, change_output_weight); - let change_value_decreased_with_fee = change_value.saturating_sub(change_output_fee); + let change_value_decreased_with_fee = + change_value.to_sat().saturating_sub(change_output_fee); // Check dust limit again if change_value_decreased_with_fee > context.holder_dust_limit_satoshis { change_output.value = Amount::from_sat(change_value_decreased_with_fee); @@ -11956,7 +11959,7 @@ where }); } - let our_funding_contribution = contribution.value(); + let our_funding_contribution = contribution.net_value(); if our_funding_contribution == SignedAmount::ZERO { return Err(APIError::APIMisuseError { err: format!( @@ -18254,6 +18257,13 @@ mod tests { FundingTxInput::new_p2wpkh(prevtx, 0).unwrap() } + fn funding_output_sats(output_value_sats: u64) -> TxOut { + TxOut { + value: Amount::from_sat(output_value_sats), + script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()), + } + } + #[test] #[rustfmt::skip] fn test_check_v2_funding_inputs_sufficient() { @@ -18264,16 +18274,83 @@ mod tests { let expected_fee = if cfg!(feature = "grind_signatures") { 2278 } else { 2284 }; assert_eq!( check_v2_funding_inputs_sufficient( - 220_000, + Amount::from_sat(220_000), + &[ + funding_input_sats(200_000), + funding_input_sats(100_000), + ], + &[], + true, + true, + 2000, + ).unwrap(), + Amount::from_sat(expected_fee), + ); + } + + // Net splice-in + { + let expected_fee = if cfg!(feature = "grind_signatures") { 2526 } else { 2532 }; + assert_eq!( + check_v2_funding_inputs_sufficient( + Amount::from_sat(220_000), + &[ + funding_input_sats(200_000), + funding_input_sats(100_000), + ], + &[ + funding_output_sats(200_000), + ], + true, + true, + 2000, + ).unwrap(), + Amount::from_sat(expected_fee), + ); + } + + // Net splice-out + { + let expected_fee = if cfg!(feature = "grind_signatures") { 2526 } else { 2532 }; + assert_eq!( + check_v2_funding_inputs_sufficient( + Amount::from_sat(220_000), &[ funding_input_sats(200_000), funding_input_sats(100_000), ], + &[ + funding_output_sats(400_000), + ], true, true, 2000, ).unwrap(), - expected_fee, + Amount::from_sat(expected_fee), + ); + } + + // Net splice-out, inputs insufficient to cover fees + { + let expected_fee = if cfg!(feature = "grind_signatures") { 113670 } else { 113940 }; + assert_eq!( + check_v2_funding_inputs_sufficient( + Amount::from_sat(220_000), + &[ + funding_input_sats(200_000), + funding_input_sats(100_000), + ], + &[ + funding_output_sats(400_000), + ], + true, + true, + 90000, + ), + Err(format!( + "Total input amount 0.00300000 BTC is lower than needed for splice-in contribution 0.00220000 BTC, considering fees of {}. Need more inputs.", + Amount::from_sat(expected_fee), + )), ); } @@ -18282,17 +18359,18 @@ mod tests { let expected_fee = if cfg!(feature = "grind_signatures") { 1736 } else { 1740 }; assert_eq!( check_v2_funding_inputs_sufficient( - 220_000, + Amount::from_sat(220_000), &[ funding_input_sats(100_000), ], + &[], true, true, 2000, ), Err(format!( - "Total input amount 100000 is lower than needed for contribution 220000, considering fees of {}. Need more inputs.", - expected_fee, + "Total input amount 0.00100000 BTC is lower than needed for splice-in contribution 0.00220000 BTC, considering fees of {}. Need more inputs.", + Amount::from_sat(expected_fee), )), ); } @@ -18302,16 +18380,17 @@ mod tests { let expected_fee = if cfg!(feature = "grind_signatures") { 2278 } else { 2284 }; assert_eq!( check_v2_funding_inputs_sufficient( - (300_000 - expected_fee - 20) as i64, + Amount::from_sat(300_000 - expected_fee - 20), &[ funding_input_sats(200_000), funding_input_sats(100_000), ], + &[], true, true, 2000, ).unwrap(), - expected_fee, + Amount::from_sat(expected_fee), ); } @@ -18320,18 +18399,19 @@ mod tests { let expected_fee = if cfg!(feature = "grind_signatures") { 2506 } else { 2513 }; assert_eq!( check_v2_funding_inputs_sufficient( - 298032, + Amount::from_sat(298032), &[ funding_input_sats(200_000), funding_input_sats(100_000), ], + &[], true, true, 2200, ), Err(format!( - "Total input amount 300000 is lower than needed for contribution 298032, considering fees of {}. Need more inputs.", - expected_fee + "Total input amount 0.00300000 BTC is lower than needed for splice-in contribution 0.00298032 BTC, considering fees of {}. Need more inputs.", + Amount::from_sat(expected_fee), )), ); } @@ -18341,16 +18421,17 @@ mod tests { let expected_fee = if cfg!(feature = "grind_signatures") { 1084 } else { 1088 }; assert_eq!( check_v2_funding_inputs_sufficient( - (300_000 - expected_fee - 20) as i64, + Amount::from_sat(300_000 - expected_fee - 20), &[ funding_input_sats(200_000), funding_input_sats(100_000), ], + &[], false, false, 2000, ).unwrap(), - expected_fee, + Amount::from_sat(expected_fee), ); } } diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index f80b2b6daea..b6068b739bb 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -20,69 +20,92 @@ use crate::sign::{P2TR_KEY_PATH_WITNESS_WEIGHT, P2WPKH_WITNESS_WEIGHT}; /// The components of a splice's funding transaction that are contributed by one party. #[derive(Debug, Clone)] -pub enum SpliceContribution { - /// When funds are added to a channel. - SpliceIn { - /// The amount to contribute to the splice. - value: Amount, - - /// The inputs included in the splice's funding transaction to meet the contributed amount - /// plus fees. Any excess amount will be sent to a change output. - inputs: Vec, - - /// An optional change output script. This will be used if needed or, when not set, - /// generated using [`SignerProvider::get_destination_script`]. - /// - /// [`SignerProvider::get_destination_script`]: crate::sign::SignerProvider::get_destination_script - change_script: Option, - }, - /// When funds are removed from a channel. - SpliceOut { - /// The outputs to include in the splice's funding transaction. The total value of all - /// outputs plus fees will be the amount that is removed. - outputs: Vec, - }, +pub struct SpliceContribution { + /// The amount to contribute to the splice. + value: SignedAmount, + + /// The inputs included in the splice's funding transaction to meet the contributed amount + /// plus fees. Any excess amount will be sent to a change output. + inputs: Vec, + + /// The outputs to include in the splice's funding transaction. The total value of all + /// outputs plus fees will be the amount that is removed. + outputs: Vec, + + /// An optional change output script. This will be used if needed or, when not set, + /// generated using [`SignerProvider::get_destination_script`]. + /// + /// [`SignerProvider::get_destination_script`]: crate::sign::SignerProvider::get_destination_script + change_script: Option, } impl SpliceContribution { - pub(super) fn value(&self) -> SignedAmount { - match self { - SpliceContribution::SpliceIn { value, .. } => { - value.to_signed().unwrap_or(SignedAmount::MAX) - }, - SpliceContribution::SpliceOut { outputs } => { - let value_removed = outputs - .iter() - .map(|txout| txout.value) - .sum::() - .to_signed() - .unwrap_or(SignedAmount::MAX); - -value_removed - }, + /// Creates a contribution for when funds are only added to a channel. + pub fn splice_in( + value: Amount, inputs: Vec, change_script: Option, + ) -> Self { + let value_added = value.to_signed().unwrap_or(SignedAmount::MAX); + + Self { value: value_added, inputs, outputs: vec![], change_script } + } + + /// Creates a contribution for when funds are only removed from a channel. + pub fn splice_out(outputs: Vec) -> Self { + let value_removed = outputs + .iter() + .map(|txout| txout.value) + .sum::() + .to_signed() + .unwrap_or(SignedAmount::MAX); + + Self { value: -value_removed, inputs: vec![], outputs, change_script: None } + } + + /// Creates a contribution for when funds are both added to and removed from a channel. + /// + /// Note that `value_added` represents the value added by `inputs` but should not account for + /// value removed by `outputs`. The net value contributed can be obtained by calling + /// [`SpliceContribution::net_value`]. + pub fn splice_in_and_out( + value_added: Amount, inputs: Vec, outputs: Vec, + change_script: Option, + ) -> Self { + let splice_in = Self::splice_in(value_added, inputs, change_script); + let splice_out = Self::splice_out(outputs); + + Self { + value: splice_in.value + splice_out.value, + inputs: splice_in.inputs, + outputs: splice_out.outputs, + change_script: splice_in.change_script, } } + /// The net value contributed to a channel by the splice. If negative, more value will be + /// spliced out than spliced in. + pub fn net_value(&self) -> SignedAmount { + self.value + } + + pub(super) fn input_value(&self) -> Amount { + (self.net_value() + self.output_value().to_signed().expect("")).to_unsigned().expect("") + } + + pub(super) fn output_value(&self) -> Amount { + self.outputs.iter().map(|txout| txout.value).sum::() + } + pub(super) fn inputs(&self) -> &[FundingTxInput] { - match self { - SpliceContribution::SpliceIn { inputs, .. } => &inputs[..], - SpliceContribution::SpliceOut { .. } => &[], - } + &self.inputs[..] } pub(super) fn outputs(&self) -> &[TxOut] { - match self { - SpliceContribution::SpliceIn { .. } => &[], - SpliceContribution::SpliceOut { outputs } => &outputs[..], - } + &self.outputs[..] } pub(super) fn into_tx_parts(self) -> (Vec, Vec, Option) { - match self { - SpliceContribution::SpliceIn { inputs, change_script, .. } => { - (inputs, vec![], change_script) - }, - SpliceContribution::SpliceOut { outputs } => (vec![], outputs, None), - } + let SpliceContribution { value: _, inputs, outputs, change_script } = self; + (inputs, outputs, change_script) } } diff --git a/lightning/src/ln/interactivetxs.rs b/lightning/src/ln/interactivetxs.rs index 4340aad420a..7ed829886c6 100644 --- a/lightning/src/ln/interactivetxs.rs +++ b/lightning/src/ln/interactivetxs.rs @@ -2337,22 +2337,21 @@ impl InteractiveTxConstructor { pub(super) fn calculate_change_output_value( context: &FundingNegotiationContext, is_splice: bool, shared_output_funding_script: &ScriptBuf, change_output_dust_limit: u64, -) -> Result, AbortReason> { - assert!(context.our_funding_contribution > SignedAmount::ZERO); - let our_funding_contribution_satoshis = context.our_funding_contribution.to_sat() as u64; - - let mut total_input_satoshis = 0u64; +) -> Result, AbortReason> { + let mut total_input_value = Amount::ZERO; let mut our_funding_inputs_weight = 0u64; for FundingTxInput { utxo, .. } in context.our_funding_inputs.iter() { - total_input_satoshis = total_input_satoshis.saturating_add(utxo.output.value.to_sat()); + total_input_value = total_input_value.checked_add(utxo.output.value).unwrap_or(Amount::MAX); let weight = BASE_INPUT_WEIGHT + utxo.satisfaction_weight; our_funding_inputs_weight = our_funding_inputs_weight.saturating_add(weight); } let funding_outputs = &context.our_funding_outputs; - let total_output_satoshis = - funding_outputs.iter().fold(0u64, |total, out| total.saturating_add(out.value.to_sat())); + let total_output_value = funding_outputs + .iter() + .fold(Amount::ZERO, |total, out| total.checked_add(out.value).unwrap_or(Amount::MAX)); + let our_funding_outputs_weight = funding_outputs.iter().fold(0u64, |weight, out| { weight.saturating_add(get_output_weight(&out.script_pubkey).to_wu()) }); @@ -2376,15 +2375,25 @@ pub(super) fn calculate_change_output_value( } } - let fees_sats = fee_for_weight(context.funding_feerate_sat_per_1000_weight, weight); - let net_total_less_fees = - total_input_satoshis.saturating_sub(total_output_satoshis).saturating_sub(fees_sats); - if net_total_less_fees < our_funding_contribution_satoshis { + let contributed_fees = + Amount::from_sat(fee_for_weight(context.funding_feerate_sat_per_1000_weight, weight)); + + let contributed_input_value = + context.our_funding_contribution + total_output_value.to_signed().unwrap(); + assert!(contributed_input_value > SignedAmount::ZERO); + let contributed_input_value = contributed_input_value.unsigned_abs(); + + let total_input_value_less_fees = + total_input_value.checked_sub(contributed_fees).unwrap_or(Amount::ZERO); + if total_input_value_less_fees < contributed_input_value { // Not enough to cover contribution plus fees return Err(AbortReason::InsufficientFees); } - let remaining_value = net_total_less_fees.saturating_sub(our_funding_contribution_satoshis); - if remaining_value < change_output_dust_limit { + + let remaining_value = total_input_value_less_fees + .checked_sub(contributed_input_value) + .expect("remaining_value should not be negative"); + if remaining_value.to_sat() < change_output_dust_limit { // Enough to cover contribution plus fees, but leftover is below dust limit; no change Ok(None) } else { @@ -3440,14 +3449,14 @@ mod tests { total_inputs - total_outputs - context.our_funding_contribution.to_unsigned().unwrap(); assert_eq!( calculate_change_output_value(&context, false, &ScriptBuf::new(), 300), - Ok(Some((gross_change - fees - common_fees).to_sat())), + Ok(Some(gross_change - fees - common_fees)), ); // There is leftover for change, without common fees let context = FundingNegotiationContext { is_initiator: false, ..context }; assert_eq!( calculate_change_output_value(&context, false, &ScriptBuf::new(), 300), - Ok(Some((gross_change - fees).to_sat())), + Ok(Some(gross_change - fees)), ); // Insufficient inputs, no leftover @@ -3482,7 +3491,7 @@ mod tests { total_inputs - total_outputs - context.our_funding_contribution.to_unsigned().unwrap(); assert_eq!( calculate_change_output_value(&context, false, &ScriptBuf::new(), 100), - Ok(Some((gross_change - fees).to_sat())), + Ok(Some(gross_change - fees)), ); // Larger fee, smaller change @@ -3496,7 +3505,7 @@ mod tests { total_inputs - total_outputs - context.our_funding_contribution.to_unsigned().unwrap(); assert_eq!( calculate_change_output_value(&context, false, &ScriptBuf::new(), 300), - Ok(Some((gross_change - fees * 3 - common_fees * 3).to_sat())), + Ok(Some(gross_change - fees * 3 - common_fees * 3)), ); } diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index a96af7bbc5d..1f037b3dae8 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -27,8 +27,9 @@ use crate::util::errors::APIError; use crate::util::ser::Writeable; use crate::util::test_channel_signer::SignerOp; +use bitcoin::hashes::Hash; use bitcoin::secp256k1::PublicKey; -use bitcoin::{Amount, OutPoint as BitcoinOutPoint, ScriptBuf, Transaction, TxOut}; +use bitcoin::{Amount, OutPoint as BitcoinOutPoint, ScriptBuf, Transaction, TxOut, WPubkeyHash}; #[test] fn test_v1_splice_in_negative_insufficient_inputs() { @@ -48,11 +49,8 @@ fn test_v1_splice_in_negative_insufficient_inputs() { let funding_inputs = create_dual_funding_utxos_with_prev_txs(&nodes[0], &[extra_splice_funding_input_sats]); - let contribution = SpliceContribution::SpliceIn { - value: Amount::from_sat(splice_in_sats), - inputs: funding_inputs, - change_script: None, - }; + let contribution = + SpliceContribution::splice_in(Amount::from_sat(splice_in_sats), funding_inputs, None); // Initiate splice-in, with insufficient input contribution let res = nodes[0].node.splice_channel( @@ -425,12 +423,10 @@ fn do_test_splice_state_reset_on_disconnect(reload: bool) { let (_, _, channel_id, _) = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 50_000_000); - let contribution = SpliceContribution::SpliceOut { - outputs: vec![TxOut { - value: Amount::from_sat(1_000), - script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), - }], - }; + let contribution = SpliceContribution::splice_out(vec![TxOut { + value: Amount::from_sat(1_000), + script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), + }]); nodes[0] .node .splice_channel( @@ -683,12 +679,10 @@ fn test_config_reject_inbound_splices() { let (_, _, channel_id, _) = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 50_000_000); - let contribution = SpliceContribution::SpliceOut { - outputs: vec![TxOut { - value: Amount::from_sat(1_000), - script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), - }], - }; + let contribution = SpliceContribution::splice_out(vec![TxOut { + value: Amount::from_sat(1_000), + script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), + }]); nodes[0] .node .splice_channel( @@ -746,16 +740,27 @@ fn test_splice_in() { let coinbase_tx1 = provide_anchor_reserves(&nodes); let coinbase_tx2 = provide_anchor_reserves(&nodes); - let initiator_contribution = SpliceContribution::SpliceIn { - value: Amount::from_sat(initial_channel_value_sat * 2), - inputs: vec![ + + let added_value = Amount::from_sat(initial_channel_value_sat * 2); + let change_script = ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()); + let fees = Amount::from_sat(321); + + let initiator_contribution = SpliceContribution::splice_in( + added_value, + vec![ FundingTxInput::new_p2wpkh(coinbase_tx1, 0).unwrap(), FundingTxInput::new_p2wpkh(coinbase_tx2, 0).unwrap(), ], - change_script: Some(nodes[0].wallet_source.get_change_script().unwrap()), - }; + Some(change_script.clone()), + ); let splice_tx = splice_channel(&nodes[0], &nodes[1], channel_id, initiator_contribution); + let expected_change = Amount::ONE_BTC * 2 - added_value - fees; + assert_eq!( + splice_tx.output.iter().find(|txout| txout.script_pubkey == change_script).unwrap().value, + expected_change, + ); + mine_transaction(&nodes[0], &splice_tx); mine_transaction(&nodes[1], &splice_tx); @@ -785,32 +790,194 @@ fn test_splice_out() { let _ = send_payment(&nodes[0], &[&nodes[1]], 100_000); - let initiator_contribution = SpliceContribution::SpliceOut { - outputs: vec![ + let initiator_contribution = SpliceContribution::splice_out(vec![ + TxOut { + value: Amount::from_sat(initial_channel_value_sat / 4), + script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), + }, + TxOut { + value: Amount::from_sat(initial_channel_value_sat / 4), + script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), + }, + ]); + + let splice_tx = splice_channel(&nodes[0], &nodes[1], channel_id, initiator_contribution); + mine_transaction(&nodes[0], &splice_tx); + mine_transaction(&nodes[1], &splice_tx); + + let htlc_limit_msat = nodes[0].node.list_channels()[0].next_outbound_htlc_limit_msat; + assert!(htlc_limit_msat < initial_channel_value_sat / 2 * 1000); + let _ = send_payment(&nodes[0], &[&nodes[1]], htlc_limit_msat); + + lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1); + + let htlc_limit_msat = nodes[0].node.list_channels()[0].next_outbound_htlc_limit_msat; + assert!(htlc_limit_msat < initial_channel_value_sat / 2 * 1000); + let _ = send_payment(&nodes[0], &[&nodes[1]], htlc_limit_msat); +} + +#[test] +fn test_splice_in_and_out() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let mut config = test_default_channel_config(); + config.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 100; + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, Some(config)]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let _ = send_payment(&nodes[0], &[&nodes[1]], 100_000); + + let coinbase_tx1 = provide_anchor_reserves(&nodes); + let coinbase_tx2 = provide_anchor_reserves(&nodes); + + // Contribute a net negative value, with fees taken from the contributed inputs and the + // remaining value sent to change + let htlc_limit_msat = nodes[0].node.list_channels()[0].next_outbound_htlc_limit_msat; + let added_value = Amount::from_sat(htlc_limit_msat / 1000); + let removed_value = added_value * 2; + let change_script = ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()); + let fees = if cfg!(feature = "grind_signatures") { + Amount::from_sat(383) + } else { + Amount::from_sat(384) + }; + + assert!(htlc_limit_msat > initial_channel_value_sat / 2 * 1000); + + let initiator_contribution = SpliceContribution::splice_in_and_out( + added_value, + vec![ + FundingTxInput::new_p2wpkh(coinbase_tx1, 0).unwrap(), + FundingTxInput::new_p2wpkh(coinbase_tx2, 0).unwrap(), + ], + vec![ TxOut { - value: Amount::from_sat(initial_channel_value_sat / 4), + value: removed_value / 2, script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), }, TxOut { - value: Amount::from_sat(initial_channel_value_sat / 4), + value: removed_value / 2, script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), }, ], - }; + Some(change_script.clone()), + ); let splice_tx = splice_channel(&nodes[0], &nodes[1], channel_id, initiator_contribution); + let expected_change = Amount::ONE_BTC * 2 - added_value - fees; + assert_eq!( + splice_tx.output.iter().find(|txout| txout.script_pubkey == change_script).unwrap().value, + expected_change, + ); + mine_transaction(&nodes[0], &splice_tx); mine_transaction(&nodes[1], &splice_tx); let htlc_limit_msat = nodes[0].node.list_channels()[0].next_outbound_htlc_limit_msat; - assert!(htlc_limit_msat < initial_channel_value_sat / 2 * 1000); + assert!(htlc_limit_msat < added_value.to_sat() * 1000); let _ = send_payment(&nodes[0], &[&nodes[1]], htlc_limit_msat); lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1); let htlc_limit_msat = nodes[0].node.list_channels()[0].next_outbound_htlc_limit_msat; - assert!(htlc_limit_msat < initial_channel_value_sat / 2 * 1000); + assert!(htlc_limit_msat < added_value.to_sat() * 1000); let _ = send_payment(&nodes[0], &[&nodes[1]], htlc_limit_msat); + + let coinbase_tx1 = provide_anchor_reserves(&nodes); + let coinbase_tx2 = provide_anchor_reserves(&nodes); + + // Contribute a net positive value, with fees taken from the contributed inputs and the + // remaining value sent to change + let added_value = Amount::from_sat(initial_channel_value_sat * 2); + let removed_value = added_value / 2; + let change_script = ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()); + let fees = if cfg!(feature = "grind_signatures") { + Amount::from_sat(383) + } else { + Amount::from_sat(384) + }; + + let initiator_contribution = SpliceContribution::splice_in_and_out( + added_value, + vec![ + FundingTxInput::new_p2wpkh(coinbase_tx1, 0).unwrap(), + FundingTxInput::new_p2wpkh(coinbase_tx2, 0).unwrap(), + ], + vec![ + TxOut { + value: removed_value / 2, + script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), + }, + TxOut { + value: removed_value / 2, + script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), + }, + ], + Some(change_script.clone()), + ); + + let splice_tx = splice_channel(&nodes[0], &nodes[1], channel_id, initiator_contribution); + let expected_change = Amount::ONE_BTC * 2 - added_value - fees; + assert_eq!( + splice_tx.output.iter().find(|txout| txout.script_pubkey == change_script).unwrap().value, + expected_change, + ); + + mine_transaction(&nodes[0], &splice_tx); + mine_transaction(&nodes[1], &splice_tx); + + let htlc_limit_msat = nodes[0].node.list_channels()[0].next_outbound_htlc_limit_msat; + assert_eq!(htlc_limit_msat, 0); + + lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1); + + let htlc_limit_msat = nodes[0].node.list_channels()[0].next_outbound_htlc_limit_msat; + assert!(htlc_limit_msat > initial_channel_value_sat / 2 * 1000); + let _ = send_payment(&nodes[0], &[&nodes[1]], htlc_limit_msat); + + let coinbase_tx1 = provide_anchor_reserves(&nodes); + let coinbase_tx2 = provide_anchor_reserves(&nodes); + + // Fail adding a net contribution value of zero + let added_value = Amount::from_sat(initial_channel_value_sat * 2); + let removed_value = added_value; + let change_script = ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()); + + let initiator_contribution = SpliceContribution::splice_in_and_out( + added_value, + vec![ + FundingTxInput::new_p2wpkh(coinbase_tx1, 0).unwrap(), + FundingTxInput::new_p2wpkh(coinbase_tx2, 0).unwrap(), + ], + vec![ + TxOut { + value: removed_value / 2, + script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), + }, + TxOut { + value: removed_value / 2, + script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), + }, + ], + Some(change_script), + ); + + assert_eq!( + nodes[0].node.splice_channel( + &channel_id, + &nodes[1].node.get_our_node_id(), + initiator_contribution, + FEERATE_FLOOR_SATS_PER_KW, + None, + ), + Err(APIError::APIMisuseError { + err: format!("Channel {} cannot be spliced; contribution cannot be zero", channel_id), + }), + ); } #[cfg(test)] @@ -854,11 +1021,11 @@ fn do_test_splice_commitment_broadcast(splice_status: SpliceStatus, claim_htlcs: let payment_amount = 1_000_000; let (preimage1, payment_hash1, ..) = route_payment(&nodes[0], &[&nodes[1]], payment_amount); let splice_in_amount = initial_channel_capacity / 2; - let initiator_contribution = SpliceContribution::SpliceIn { - value: Amount::from_sat(splice_in_amount), - inputs: vec![FundingTxInput::new_p2wpkh(coinbase_tx.clone(), 0).unwrap()], - change_script: Some(nodes[0].wallet_source.get_change_script().unwrap()), - }; + let initiator_contribution = SpliceContribution::splice_in( + Amount::from_sat(splice_in_amount), + vec![FundingTxInput::new_p2wpkh(coinbase_tx.clone(), 0).unwrap()], + Some(nodes[0].wallet_source.get_change_script().unwrap()), + ); let splice_tx = splice_channel(&nodes[0], &nodes[1], channel_id, initiator_contribution); let (preimage2, payment_hash2, ..) = route_payment(&nodes[0], &[&nodes[1]], payment_amount); let htlc_expiry = nodes[0].best_block_info().1 + TEST_FINAL_CLTV + LATENCY_GRACE_PERIOD_BLOCKS; @@ -1052,18 +1219,16 @@ fn do_test_splice_reestablish(reload: bool, async_monitor_update: bool) { route_payment(&nodes[0], &[&nodes[1]], 1_000_000); // Negotiate the splice up until the nodes exchange `tx_complete`. - let initiator_contribution = SpliceContribution::SpliceOut { - outputs: vec![ - TxOut { - value: Amount::from_sat(initial_channel_value_sat / 4), - script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), - }, - TxOut { - value: Amount::from_sat(initial_channel_value_sat / 4), - script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), - }, - ], - }; + let initiator_contribution = SpliceContribution::splice_out(vec![ + TxOut { + value: Amount::from_sat(initial_channel_value_sat / 4), + script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), + }, + TxOut { + value: Amount::from_sat(initial_channel_value_sat / 4), + script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), + }, + ]); let initial_commit_sig_for_acceptor = negotiate_splice_tx(&nodes[0], &nodes[1], channel_id, initiator_contribution); assert_eq!(initial_commit_sig_for_acceptor.htlc_signatures.len(), 1); @@ -1340,12 +1505,10 @@ fn do_test_propose_splice_while_disconnected(reload: bool, use_0conf: bool) { nodes[1].node.peer_disconnected(node_id_0); let splice_out_sat = initial_channel_value_sat / 4; - let node_0_contribution = SpliceContribution::SpliceOut { - outputs: vec![TxOut { - value: Amount::from_sat(splice_out_sat), - script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), - }], - }; + let node_0_contribution = SpliceContribution::splice_out(vec![TxOut { + value: Amount::from_sat(splice_out_sat), + script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), + }]); nodes[0] .node .splice_channel( @@ -1358,12 +1521,10 @@ fn do_test_propose_splice_while_disconnected(reload: bool, use_0conf: bool) { .unwrap(); assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty()); - let node_1_contribution = SpliceContribution::SpliceOut { - outputs: vec![TxOut { - value: Amount::from_sat(splice_out_sat), - script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), - }], - }; + let node_1_contribution = SpliceContribution::splice_out(vec![TxOut { + value: Amount::from_sat(splice_out_sat), + script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), + }]); nodes[1] .node .splice_channel( @@ -1616,11 +1777,11 @@ fn disconnect_on_unexpected_interactive_tx_message() { let coinbase_tx = provide_anchor_reserves(&nodes); let splice_in_amount = initial_channel_capacity / 2; - let contribution = SpliceContribution::SpliceIn { - value: Amount::from_sat(splice_in_amount), - inputs: vec![FundingTxInput::new_p2wpkh(coinbase_tx, 0).unwrap()], - change_script: Some(nodes[0].wallet_source.get_change_script().unwrap()), - }; + let contribution = SpliceContribution::splice_in( + Amount::from_sat(splice_in_amount), + vec![FundingTxInput::new_p2wpkh(coinbase_tx, 0).unwrap()], + Some(nodes[0].wallet_source.get_change_script().unwrap()), + ); // Complete interactive-tx construction, but fail by having the acceptor send a duplicate // tx_complete instead of commitment_signed. @@ -1656,11 +1817,11 @@ fn fail_splice_on_interactive_tx_error() { let coinbase_tx = provide_anchor_reserves(&nodes); let splice_in_amount = initial_channel_capacity / 2; - let contribution = SpliceContribution::SpliceIn { - value: Amount::from_sat(splice_in_amount), - inputs: vec![FundingTxInput::new_p2wpkh(coinbase_tx, 0).unwrap()], - change_script: Some(nodes[0].wallet_source.get_change_script().unwrap()), - }; + let contribution = SpliceContribution::splice_in( + Amount::from_sat(splice_in_amount), + vec![FundingTxInput::new_p2wpkh(coinbase_tx, 0).unwrap()], + Some(nodes[0].wallet_source.get_change_script().unwrap()), + ); // Fail during interactive-tx construction by having the acceptor echo back tx_add_input instead // of sending tx_complete. The failure occurs because the serial id will have the wrong parity. @@ -1762,11 +1923,11 @@ fn fail_splice_on_tx_abort() { let coinbase_tx = provide_anchor_reserves(&nodes); let splice_in_amount = initial_channel_capacity / 2; - let contribution = SpliceContribution::SpliceIn { - value: Amount::from_sat(splice_in_amount), - inputs: vec![FundingTxInput::new_p2wpkh(coinbase_tx, 0).unwrap()], - change_script: Some(nodes[0].wallet_source.get_change_script().unwrap()), - }; + let contribution = SpliceContribution::splice_in( + Amount::from_sat(splice_in_amount), + vec![FundingTxInput::new_p2wpkh(coinbase_tx, 0).unwrap()], + Some(nodes[0].wallet_source.get_change_script().unwrap()), + ); // Fail during interactive-tx construction by having the acceptor send tx_abort instead of // tx_complete. @@ -1816,11 +1977,11 @@ fn fail_splice_on_channel_close() { let coinbase_tx = provide_anchor_reserves(&nodes); let splice_in_amount = initial_channel_capacity / 2; - let contribution = SpliceContribution::SpliceIn { - value: Amount::from_sat(splice_in_amount), - inputs: vec![FundingTxInput::new_p2wpkh(coinbase_tx, 0).unwrap()], - change_script: Some(nodes[0].wallet_source.get_change_script().unwrap()), - }; + let contribution = SpliceContribution::splice_in( + Amount::from_sat(splice_in_amount), + vec![FundingTxInput::new_p2wpkh(coinbase_tx, 0).unwrap()], + Some(nodes[0].wallet_source.get_change_script().unwrap()), + ); // Close the channel before completion of interactive-tx construction. let _ = complete_splice_handshake(initiator, acceptor, channel_id, contribution.clone()); @@ -1867,11 +2028,11 @@ fn fail_quiescent_action_on_channel_close() { let coinbase_tx = provide_anchor_reserves(&nodes); let splice_in_amount = initial_channel_capacity / 2; - let contribution = SpliceContribution::SpliceIn { - value: Amount::from_sat(splice_in_amount), - inputs: vec![FundingTxInput::new_p2wpkh(coinbase_tx, 0).unwrap()], - change_script: Some(nodes[0].wallet_source.get_change_script().unwrap()), - }; + let contribution = SpliceContribution::splice_in( + Amount::from_sat(splice_in_amount), + vec![FundingTxInput::new_p2wpkh(coinbase_tx, 0).unwrap()], + Some(nodes[0].wallet_source.get_change_script().unwrap()), + ); // Close the channel before completion of STFU handshake. initiator @@ -1960,23 +2121,19 @@ fn do_test_splice_with_inflight_htlc_forward_and_resolution(expire_scid_pre_forw // Splice both channels, lock them, and connect enough blocks to trigger the legacy SCID pruning // logic while the HTLC is still pending. - let contribution = SpliceContribution::SpliceOut { - outputs: vec![TxOut { - value: Amount::from_sat(1_000), - script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), - }], - }; + let contribution = SpliceContribution::splice_out(vec![TxOut { + value: Amount::from_sat(1_000), + script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), + }]); let splice_tx_0_1 = splice_channel(&nodes[0], &nodes[1], channel_id_0_1, contribution); for node in &nodes { mine_transaction(node, &splice_tx_0_1); } - let contribution = SpliceContribution::SpliceOut { - outputs: vec![TxOut { - value: Amount::from_sat(1_000), - script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), - }], - }; + let contribution = SpliceContribution::splice_out(vec![TxOut { + value: Amount::from_sat(1_000), + script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), + }]); let splice_tx_1_2 = splice_channel(&nodes[1], &nodes[2], channel_id_1_2, contribution); for node in &nodes { mine_transaction(node, &splice_tx_1_2);