diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index d6fbd162388..1bab840a1af 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -10,7 +10,7 @@ use bitcoin::amount::Amount; use bitcoin::constants::ChainHash; use bitcoin::script::{Script, ScriptBuf, Builder, WScriptHash}; -use bitcoin::transaction::{Transaction, TxIn}; +use bitcoin::transaction::{Transaction, TxIn, TxOut}; use bitcoin::sighash::EcdsaSighashType; use bitcoin::consensus::encode; use bitcoin::absolute::LockTime; @@ -30,9 +30,9 @@ use crate::ln::types::ChannelId; use crate::types::payment::{PaymentPreimage, PaymentHash}; use crate::types::features::{ChannelTypeFeatures, InitFeatures}; use crate::ln::interactivetxs::{ - get_output_weight, HandleTxCompleteValue, HandleTxCompleteResult, InteractiveTxConstructor, - InteractiveTxConstructorArgs, InteractiveTxSigningSession, InteractiveTxMessageSendResult, - TX_COMMON_FIELDS_WEIGHT, + calculate_change_output_value, get_output_weight, AbortReason, HandleTxCompleteValue, HandleTxCompleteResult, InteractiveTxConstructor, + InteractiveTxConstructorArgs, InteractiveTxMessageSend, InteractiveTxSigningSession, InteractiveTxMessageSendResult, + OutputOwned, SharedOwnedOutput, TX_COMMON_FIELDS_WEIGHT, }; use crate::ln::msgs; use crate::ln::msgs::{ClosingSigned, ClosingSignedFeeRange, DecodeError, OnionErrorPacket}; @@ -2220,6 +2220,95 @@ impl InitialRemoteCommitmentReceiver for FundedChannel where } impl PendingV2Channel where SP::Target: SignerProvider { + /// Prepare and start interactive transaction negotiation. + /// `change_destination_opt` - Optional destination for optional change; if None, + /// default destination address is used. + /// If error occurs, it is caused by our side, not the counterparty. + #[allow(dead_code)] // TODO(dual_funding): Remove once contribution to V2 channels is enabled + fn begin_interactive_funding_tx_construction( + &mut self, signer_provider: &SP, entropy_source: &ES, holder_node_id: PublicKey, + change_destination_opt: Option, + ) -> Result, AbortReason> + where ES::Target: EntropySource + { + debug_assert!(matches!(self.context.channel_state, ChannelState::NegotiatingFunding(_))); + debug_assert!(self.interactive_tx_constructor.is_none()); + + let mut funding_inputs = Vec::new(); + mem::swap(&mut self.dual_funding_context.our_funding_inputs, &mut funding_inputs); + + // TODO(splicing): Add prev funding tx as input, must be provided as a parameter + + // Add output for funding tx + // Note: For the error case when the inputs are insufficient, it will be handled after + // the `calculate_change_output_value` call below + let mut funding_outputs = Vec::new(); + let mut expected_remote_shared_funding_output = None; + + let shared_funding_output = TxOut { + value: Amount::from_sat(self.funding.get_value_satoshis()), + script_pubkey: self.funding.get_funding_redeemscript().to_p2wsh(), + }; + + if self.funding.is_outbound() { + funding_outputs.push( + OutputOwned::Shared(SharedOwnedOutput::new( + shared_funding_output, self.dual_funding_context.our_funding_satoshis, + )) + ); + } else { + let TxOut { value, script_pubkey } = shared_funding_output; + expected_remote_shared_funding_output = Some((script_pubkey, value.to_sat())); + } + + // Optionally add change output + let change_script = if let Some(script) = change_destination_opt { + script + } else { + signer_provider.get_destination_script(self.context.channel_keys_id) + .map_err(|_err| AbortReason::InternalError("Error getting destination script"))? + }; + let change_value_opt = calculate_change_output_value( + self.funding.is_outbound(), self.dual_funding_context.our_funding_satoshis, + &funding_inputs, &funding_outputs, + self.dual_funding_context.funding_feerate_sat_per_1000_weight, + change_script.minimal_non_dust().to_sat(), + )?; + if let Some(change_value) = change_value_opt { + let mut change_output = TxOut { + value: Amount::from_sat(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.dual_funding_context.funding_feerate_sat_per_1000_weight, change_output_weight); + let change_value_decreased_with_fee = change_value.saturating_sub(change_output_fee); + // Check dust limit again + if change_value_decreased_with_fee > self.context.holder_dust_limit_satoshis { + change_output.value = Amount::from_sat(change_value_decreased_with_fee); + funding_outputs.push(OutputOwned::Single(change_output)); + } + } + + let constructor_args = InteractiveTxConstructorArgs { + entropy_source, + holder_node_id, + counterparty_node_id: self.context.counterparty_node_id, + channel_id: self.context.channel_id(), + feerate_sat_per_kw: self.dual_funding_context.funding_feerate_sat_per_1000_weight, + is_initiator: self.funding.is_outbound(), + funding_tx_locktime: self.dual_funding_context.funding_tx_locktime, + inputs_to_contribute: funding_inputs, + outputs_to_contribute: funding_outputs, + expected_remote_shared_funding_output, + }; + let mut tx_constructor = InteractiveTxConstructor::new(constructor_args)?; + let msg = tx_constructor.take_initiator_first_message(); + + self.interactive_tx_constructor = Some(tx_constructor); + + Ok(msg) + } + pub fn tx_add_input(&mut self, msg: &msgs::TxAddInput) -> InteractiveTxMessageSendResult { InteractiveTxMessageSendResult(match &mut self.interactive_tx_constructor { Some(ref mut tx_constructor) => tx_constructor.handle_tx_add_input(msg).map_err( @@ -4849,6 +4938,9 @@ fn check_v2_funding_inputs_sufficient( pub(super) struct DualFundingChannelContext { /// The amount in satoshis we will be contributing to the channel. pub our_funding_satoshis: u64, + /// The amount in satoshis our counterparty will be contributing to the channel. + #[allow(dead_code)] // TODO(dual_funding): Remove once contribution to V2 channels is enabled. + pub their_funding_satoshis: Option, /// The funding transaction locktime suggested by the initiator. If set by us, it is always set /// to the current block height to align incentives against fee-sniping. pub funding_tx_locktime: LockTime, @@ -4860,6 +4952,8 @@ pub(super) struct DualFundingChannelContext { /// Note that the `our_funding_satoshis` field is equal to the total value of `our_funding_inputs` /// minus any fees paid for our contributed weight. This means that change will never be generated /// and the maximum value possible will go towards funding the channel. + /// + /// Note that this field may be emptied once the interactive negotiation has been started. #[allow(dead_code)] // TODO(dual_funding): Remove once contribution to V2 channels is enabled. pub our_funding_inputs: Vec<(TxIn, TransactionU16LenLimited)>, } @@ -9829,16 +9923,19 @@ impl PendingV2Channel where SP::Target: SignerProvider { unfunded_channel_age_ticks: 0, holder_commitment_point: HolderCommitmentPoint::new(&context.holder_signer, &context.secp_ctx), }; + let dual_funding_context = DualFundingChannelContext { + our_funding_satoshis: funding_satoshis, + // TODO(dual_funding) TODO(splicing) Include counterparty contribution, once that's enabled + their_funding_satoshis: None, + funding_tx_locktime, + funding_feerate_sat_per_1000_weight, + our_funding_inputs: funding_inputs, + }; let chan = Self { funding, context, unfunded_context, - dual_funding_context: DualFundingChannelContext { - our_funding_satoshis: funding_satoshis, - funding_tx_locktime, - funding_feerate_sat_per_1000_weight, - our_funding_inputs: funding_inputs, - }, + dual_funding_context, interactive_tx_constructor: None, interactive_tx_signing_session: None, }; @@ -9980,6 +10077,7 @@ impl PendingV2Channel where SP::Target: SignerProvider { let dual_funding_context = DualFundingChannelContext { our_funding_satoshis: our_funding_satoshis, + their_funding_satoshis: Some(msg.common_fields.funding_satoshis), funding_tx_locktime: LockTime::from_consensus(msg.locktime), funding_feerate_sat_per_1000_weight: msg.funding_feerate_sat_per_1000_weight, our_funding_inputs: our_funding_inputs.clone(), diff --git a/lightning/src/ln/interactivetxs.rs b/lightning/src/ln/interactivetxs.rs index 9fbcaef92d2..28dbd3d1245 100644 --- a/lightning/src/ln/interactivetxs.rs +++ b/lightning/src/ln/interactivetxs.rs @@ -91,6 +91,12 @@ pub(crate) enum AbortReason { IncorrectSerialIdParity, SerialIdUnknown, DuplicateSerialId, + /// Invalid provided inputs and previous transactions, several possible reasons: + /// - nonexisting `vout`, or + /// - mismatching `TxId`'s + /// - duplicate input, + /// - not a witness program, + /// etc. PrevTxOutInvalid, ExceededMaximumSatsAllowed, ExceededNumberOfInputsOrOutputs, @@ -108,6 +114,8 @@ pub(crate) enum AbortReason { /// if funding output is provided by the peer this is an interop error, /// if provided by the same node than internal input consistency error. InvalidLowFundingOutputValue, + /// Internal error + InternalError(&'static str), } impl AbortReason { @@ -118,36 +126,49 @@ impl AbortReason { impl Display for AbortReason { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - f.write_str(match self { - AbortReason::InvalidStateTransition => "State transition was invalid", - AbortReason::UnexpectedCounterpartyMessage => "Unexpected message", - AbortReason::ReceivedTooManyTxAddInputs => "Too many `tx_add_input`s received", - AbortReason::ReceivedTooManyTxAddOutputs => "Too many `tx_add_output`s received", + match self { + AbortReason::InvalidStateTransition => f.write_str("State transition was invalid"), + AbortReason::UnexpectedCounterpartyMessage => f.write_str("Unexpected message"), + AbortReason::ReceivedTooManyTxAddInputs => { + f.write_str("Too many `tx_add_input`s received") + }, + AbortReason::ReceivedTooManyTxAddOutputs => { + f.write_str("Too many `tx_add_output`s received") + }, AbortReason::IncorrectInputSequenceValue => { - "Input has a sequence value greater than 0xFFFFFFFD" + f.write_str("Input has a sequence value greater than 0xFFFFFFFD") + }, + AbortReason::IncorrectSerialIdParity => { + f.write_str("Parity for `serial_id` was incorrect") }, - AbortReason::IncorrectSerialIdParity => "Parity for `serial_id` was incorrect", - AbortReason::SerialIdUnknown => "The `serial_id` is unknown", - AbortReason::DuplicateSerialId => "The `serial_id` already exists", - AbortReason::PrevTxOutInvalid => "Invalid previous transaction output", + AbortReason::SerialIdUnknown => f.write_str("The `serial_id` is unknown"), + AbortReason::DuplicateSerialId => f.write_str("The `serial_id` already exists"), + AbortReason::PrevTxOutInvalid => f.write_str("Invalid previous transaction output"), AbortReason::ExceededMaximumSatsAllowed => { - "Output amount exceeded total bitcoin supply" + f.write_str("Output amount exceeded total bitcoin supply") }, - AbortReason::ExceededNumberOfInputsOrOutputs => "Too many inputs or outputs", - AbortReason::TransactionTooLarge => "Transaction weight is too large", - AbortReason::BelowDustLimit => "Output amount is below the dust limit", - AbortReason::InvalidOutputScript => "The output script is non-standard", - AbortReason::InsufficientFees => "Insufficient fees paid", + AbortReason::ExceededNumberOfInputsOrOutputs => { + f.write_str("Too many inputs or outputs") + }, + AbortReason::TransactionTooLarge => f.write_str("Transaction weight is too large"), + AbortReason::BelowDustLimit => f.write_str("Output amount is below the dust limit"), + AbortReason::InvalidOutputScript => f.write_str("The output script is non-standard"), + AbortReason::InsufficientFees => f.write_str("Insufficient fees paid"), AbortReason::OutputsValueExceedsInputsValue => { - "Total value of outputs exceeds total value of inputs" + f.write_str("Total value of outputs exceeds total value of inputs") }, - AbortReason::InvalidTx => "The transaction is invalid", - AbortReason::MissingFundingOutput => "No shared funding output found", - AbortReason::DuplicateFundingOutput => "More than one funding output found", - AbortReason::InvalidLowFundingOutputValue => { - "Local part of funding output value is greater than the funding output value" + AbortReason::InvalidTx => f.write_str("The transaction is invalid"), + AbortReason::MissingFundingOutput => f.write_str("No shared funding output found"), + AbortReason::DuplicateFundingOutput => { + f.write_str("More than one funding output found") }, - }) + AbortReason::InvalidLowFundingOutputValue => f.write_str( + "Local part of funding output value is greater than the funding output value", + ), + AbortReason::InternalError(text) => { + f.write_fmt(format_args!("Internal error: {}", text)) + }, + } } } @@ -1152,13 +1173,13 @@ pub(crate) enum InteractiveTxInput { } #[derive(Clone, Debug, Eq, PartialEq)] -pub struct SharedOwnedOutput { +pub(super) struct SharedOwnedOutput { tx_out: TxOut, local_owned: u64, } impl SharedOwnedOutput { - fn new(tx_out: TxOut, local_owned: u64) -> SharedOwnedOutput { + pub fn new(tx_out: TxOut, local_owned: u64) -> SharedOwnedOutput { debug_assert!( local_owned <= tx_out.value.to_sat(), "SharedOwnedOutput: Inconsistent local_owned value {}, larger than output value {}", @@ -1177,26 +1198,24 @@ impl SharedOwnedOutput { /// its control -- exclusive by the adder or shared --, and /// its ownership -- value fully owned by the adder or jointly #[derive(Clone, Debug, Eq, PartialEq)] -pub enum OutputOwned { +pub(super) enum OutputOwned { /// Belongs to a single party -- controlled exclusively and fully belonging to a single party Single(TxOut), - /// Output with shared control, but fully belonging to local node - SharedControlFullyOwned(TxOut), - /// Output with shared control and joint ownership + /// Output with shared control and value split between the two ends (or fully at one side) Shared(SharedOwnedOutput), } impl OutputOwned { - fn tx_out(&self) -> &TxOut { + pub fn tx_out(&self) -> &TxOut { match self { - OutputOwned::Single(tx_out) | OutputOwned::SharedControlFullyOwned(tx_out) => tx_out, + OutputOwned::Single(tx_out) => tx_out, OutputOwned::Shared(output) => &output.tx_out, } } fn into_tx_out(self) -> TxOut { match self { - OutputOwned::Single(tx_out) | OutputOwned::SharedControlFullyOwned(tx_out) => tx_out, + OutputOwned::Single(tx_out) => tx_out, OutputOwned::Shared(output) => output.tx_out, } } @@ -1208,18 +1227,15 @@ impl OutputOwned { fn is_shared(&self) -> bool { match self { OutputOwned::Single(_) => false, - OutputOwned::SharedControlFullyOwned(_) => true, OutputOwned::Shared(_) => true, } } fn local_value(&self, local_role: AddingRole) -> u64 { match self { - OutputOwned::Single(tx_out) | OutputOwned::SharedControlFullyOwned(tx_out) => { - match local_role { - AddingRole::Local => tx_out.value.to_sat(), - AddingRole::Remote => 0, - } + OutputOwned::Single(tx_out) => match local_role { + AddingRole::Local => tx_out.value.to_sat(), + AddingRole::Remote => 0, }, OutputOwned::Shared(output) => output.local_owned, } @@ -1227,11 +1243,9 @@ impl OutputOwned { fn remote_value(&self, local_role: AddingRole) -> u64 { match self { - OutputOwned::Single(tx_out) | OutputOwned::SharedControlFullyOwned(tx_out) => { - match local_role { - AddingRole::Local => 0, - AddingRole::Remote => tx_out.value.to_sat(), - } + OutputOwned::Single(tx_out) => match local_role { + AddingRole::Local => 0, + AddingRole::Remote => tx_out.value.to_sat(), }, OutputOwned::Shared(output) => output.remote_owned(), } @@ -1495,12 +1509,9 @@ impl InteractiveTxConstructor { for output in &outputs_to_contribute { let new_output = match output { OutputOwned::Single(_tx_out) => None, - OutputOwned::SharedControlFullyOwned(tx_out) => { - Some((tx_out.script_pubkey.clone(), tx_out.value.to_sat())) - }, OutputOwned::Shared(output) => { // Sanity check - if output.local_owned >= output.tx_out.value.to_sat() { + if output.local_owned > output.tx_out.value.to_sat() { return Err(AbortReason::InvalidLowFundingOutputValue); } Some((output.tx_out.script_pubkey.clone(), output.local_owned)) @@ -1663,14 +1674,77 @@ impl InteractiveTxConstructor { } } +/// Determine whether a change output should be added, and if yes, of what size, +/// considering our given inputs & outputs, and intended contribution. +/// Computes the fees, takes into account the fees and the dust limit. +/// Three outcomes are possible: +/// - Inputs are sufficient for intended contribution, fees, and a larger-than-dust change: +/// `Ok(Some(change_amount))` +/// - Inputs are sufficient for intended contribution and fees, and a change output isn't needed: +/// `Ok(None)` +/// - Inputs are not sufficent to cover contribution and fees: +/// `Err(AbortReason::InsufficientFees)` +#[allow(dead_code)] // TODO(dual_funding): Remove once begin_interactive_funding_tx_construction() is used +pub(super) fn calculate_change_output_value( + is_initiator: bool, our_contribution: u64, + funding_inputs: &Vec<(TxIn, TransactionU16LenLimited)>, funding_outputs: &Vec, + funding_feerate_sat_per_1000_weight: u32, change_output_dust_limit: u64, +) -> Result, AbortReason> { + // Process inputs and their prev txs: + // calculate value sum and weight sum of inputs, also perform checks + let mut total_input_satoshis = 0u64; + let mut our_funding_inputs_weight = 0u64; + for (txin, tx) in funding_inputs.iter() { + let txid = tx.as_transaction().compute_txid(); + if txin.previous_output.txid != txid { + return Err(AbortReason::PrevTxOutInvalid); + } + if let Some(output) = tx.as_transaction().output.get(txin.previous_output.vout as usize) { + total_input_satoshis = total_input_satoshis.saturating_add(output.value.to_sat()); + our_funding_inputs_weight = + our_funding_inputs_weight.saturating_add(estimate_input_weight(output).to_wu()); + } else { + return Err(AbortReason::PrevTxOutInvalid); + } + } + + let our_funding_outputs_weight = funding_outputs.iter().fold(0u64, |weight, out| { + weight.saturating_add(get_output_weight(&out.tx_out().script_pubkey).to_wu()) + }); + let mut weight = our_funding_outputs_weight.saturating_add(our_funding_inputs_weight); + + // If we are the initiator, we must pay for weight of all common fields in the funding transaction. + if is_initiator { + weight = weight.saturating_add(TX_COMMON_FIELDS_WEIGHT); + } + + let fees_sats = fee_for_weight(funding_feerate_sat_per_1000_weight, weight); + // Note: in case of additional outputs, they will have to be subtracted here + + let total_inputs_less_fees = total_input_satoshis.saturating_sub(fees_sats); + if total_inputs_less_fees < our_contribution { + // Not enough to cover contribution plus fees + return Err(AbortReason::InsufficientFees); + } + let remaining_value = total_inputs_less_fees.saturating_sub(our_contribution); + if remaining_value < change_output_dust_limit { + // Enough to cover contribution plus fees, but leftover is below dust limit; no change + Ok(None) + } else { + // Enough to have over-dust change + Ok(Some(remaining_value)) + } +} + #[cfg(test)] mod tests { use crate::chain::chaininterface::{fee_for_weight, FEERATE_FLOOR_SATS_PER_KW}; use crate::ln::channel::TOTAL_BITCOIN_SUPPLY_SATOSHIS; use crate::ln::interactivetxs::{ - generate_holder_serial_id, AbortReason, HandleTxCompleteValue, InteractiveTxConstructor, - InteractiveTxConstructorArgs, InteractiveTxMessageSend, MAX_INPUTS_OUTPUTS_COUNT, - MAX_RECEIVED_TX_ADD_INPUT_COUNT, MAX_RECEIVED_TX_ADD_OUTPUT_COUNT, + calculate_change_output_value, generate_holder_serial_id, AbortReason, + HandleTxCompleteValue, InteractiveTxConstructor, InteractiveTxConstructorArgs, + InteractiveTxMessageSend, MAX_INPUTS_OUTPUTS_COUNT, MAX_RECEIVED_TX_ADD_INPUT_COUNT, + MAX_RECEIVED_TX_ADD_OUTPUT_COUNT, }; use crate::ln::types::ChannelId; use crate::sign::EntropySource; @@ -1685,7 +1759,7 @@ mod tests { use bitcoin::secp256k1::{Keypair, PublicKey, Secp256k1, SecretKey}; use bitcoin::transaction::Version; use bitcoin::{ - OutPoint, PubkeyHash, ScriptBuf, Sequence, Transaction, TxIn, TxOut, WPubkeyHash, + OutPoint, PubkeyHash, ScriptBuf, Sequence, Transaction, TxIn, TxOut, WPubkeyHash, Witness, }; use core::ops::Deref; @@ -2054,7 +2128,9 @@ mod tests { /// Generate a single output that is the funding output fn generate_output(output: &TestOutput) -> Vec { - vec![OutputOwned::SharedControlFullyOwned(generate_txout(output))] + let txout = generate_txout(output); + let value = txout.value.to_sat(); + vec![OutputOwned::Shared(SharedOwnedOutput::new(txout, value))] } /// Generate a single P2WSH output that is the funding output @@ -2595,4 +2671,107 @@ mod tests { assert_eq!(generate_holder_serial_id(&&entropy_source, true) % 2, 0); assert_eq!(generate_holder_serial_id(&&entropy_source, false) % 2, 1) } + + #[test] + fn test_calculate_change_output_value_open() { + let input_prevouts = vec![ + TxOut { value: Amount::from_sat(70_000), script_pubkey: ScriptBuf::new() }, + TxOut { value: Amount::from_sat(60_000), script_pubkey: ScriptBuf::new() }, + ]; + let inputs = input_prevouts + .iter() + .map(|txout| { + let tx = Transaction { + input: Vec::new(), + output: vec![(*txout).clone()], + lock_time: AbsoluteLockTime::ZERO, + version: Version::TWO, + }; + let txid = tx.compute_txid(); + let txin = TxIn { + previous_output: OutPoint { txid, vout: 0 }, + script_sig: ScriptBuf::new(), + sequence: Sequence::ZERO, + witness: Witness::new(), + }; + (txin, TransactionU16LenLimited::new(tx).unwrap()) + }) + .collect::>(); + let our_contributed = 110_000; + let txout = TxOut { value: Amount::from_sat(128_000), script_pubkey: ScriptBuf::new() }; + let value = txout.value.to_sat(); + let outputs = vec![OutputOwned::Shared(SharedOwnedOutput::new(txout, value))]; + let funding_feerate_sat_per_1000_weight = 3000; + + let total_inputs: u64 = input_prevouts.iter().map(|o| o.value.to_sat()).sum(); + let gross_change = total_inputs - our_contributed; + let fees = 1746; + let common_fees = 126; + { + // There is leftover for change + let res = calculate_change_output_value( + true, + our_contributed, + &inputs, + &outputs, + funding_feerate_sat_per_1000_weight, + 300, + ); + assert_eq!(res.unwrap().unwrap(), gross_change - fees - common_fees); + } + { + // There is leftover for change, without common fees + let res = calculate_change_output_value( + false, + our_contributed, + &inputs, + &outputs, + funding_feerate_sat_per_1000_weight, + 300, + ); + assert_eq!(res.unwrap().unwrap(), gross_change - fees); + } + { + // Larger fee, smaller change + let res = + calculate_change_output_value(true, our_contributed, &inputs, &outputs, 9000, 300); + assert_eq!(res.unwrap().unwrap(), 14384); + } + { + // Insufficient inputs, no leftover + let res = calculate_change_output_value( + false, + 130_000, + &inputs, + &outputs, + funding_feerate_sat_per_1000_weight, + 300, + ); + assert_eq!(res.err().unwrap(), AbortReason::InsufficientFees); + } + { + // Very small leftover + let res = calculate_change_output_value( + false, + 128_100, + &inputs, + &outputs, + funding_feerate_sat_per_1000_weight, + 300, + ); + assert!(res.unwrap().is_none()); + } + { + // Small leftover, but not dust + let res = calculate_change_output_value( + false, + 128_100, + &inputs, + &outputs, + funding_feerate_sat_per_1000_weight, + 100, + ); + assert_eq!(res.unwrap().unwrap(), 154); + } + } }