X-Git-Url: http://git.bitcoin.ninja/index.cgi?a=blobdiff_plain;f=lightning%2Fsrc%2Fln%2Finteractivetxs.rs;h=b6ed64aa8182d855022c08ec881a42668c36f654;hb=3ccf06416091e107f443ee92027501105c48054b;hp=94311a3671b360ff2e8172b3c002939ab4565ba0;hpb=19bcb1c62f100dba5dfaf70e8899ae904e6d7f75;p=rust-lightning diff --git a/lightning/src/ln/interactivetxs.rs b/lightning/src/ln/interactivetxs.rs index 94311a36..b6ed64aa 100644 --- a/lightning/src/ln/interactivetxs.rs +++ b/lightning/src/ln/interactivetxs.rs @@ -9,23 +9,26 @@ use crate::io_extras::sink; use crate::prelude::*; -use core::ops::Deref; +use bitcoin::absolute::LockTime as AbsoluteLockTime; +use bitcoin::amount::Amount; use bitcoin::blockdata::constants::WITNESS_SCALE_FACTOR; use bitcoin::consensus::Encodable; use bitcoin::policy::MAX_STANDARD_TX_WEIGHT; -use bitcoin::{ - absolute::LockTime as AbsoluteLockTime, OutPoint, Sequence, Transaction, TxIn, TxOut, -}; +use bitcoin::transaction::Version; +use bitcoin::{OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Weight}; use crate::chain::chaininterface::fee_for_weight; use crate::events::bump_transaction::{BASE_INPUT_WEIGHT, EMPTY_SCRIPT_SIG_WEIGHT}; use crate::ln::channel::TOTAL_BITCOIN_SUPPLY_SATOSHIS; +use crate::ln::msgs; use crate::ln::msgs::SerialId; -use crate::ln::{msgs, ChannelId}; -use crate::sign::EntropySource; +use crate::ln::types::ChannelId; +use crate::sign::{EntropySource, P2TR_KEY_PATH_WITNESS_WEIGHT, P2WPKH_WITNESS_WEIGHT}; use crate::util::ser::TransactionU16LenLimited; +use core::ops::Deref; + /// The number of received `tx_add_input` messages during a negotiation at which point the /// negotiation MUST be failed. const MAX_RECEIVED_TX_ADD_INPUT_COUNT: u16 = 4096; @@ -38,6 +41,29 @@ const MAX_RECEIVED_TX_ADD_OUTPUT_COUNT: u16 = 4096; /// negotiation. const MAX_INPUTS_OUTPUTS_COUNT: usize = 252; +/// The total weight of the common fields whose fee is paid by the initiator of the interactive +/// transaction construction protocol. +const TX_COMMON_FIELDS_WEIGHT: u64 = (4 /* version */ + 4 /* locktime */ + 1 /* input count */ + + 1 /* output count */) * WITNESS_SCALE_FACTOR as u64 + 2 /* segwit marker + flag */; + +// BOLT 3 - Lower bounds for input weights + +/// Lower bound for P2WPKH input weight +pub(crate) const P2WPKH_INPUT_WEIGHT_LOWER_BOUND: u64 = + BASE_INPUT_WEIGHT + EMPTY_SCRIPT_SIG_WEIGHT + P2WPKH_WITNESS_WEIGHT; + +/// Lower bound for P2WSH input weight is chosen as same as P2WPKH input weight in BOLT 3 +pub(crate) const P2WSH_INPUT_WEIGHT_LOWER_BOUND: u64 = P2WPKH_INPUT_WEIGHT_LOWER_BOUND; + +/// Lower bound for P2TR input weight is chosen as the key spend path. +/// Not specified in BOLT 3, but a reasonable lower bound. +pub(crate) const P2TR_INPUT_WEIGHT_LOWER_BOUND: u64 = + BASE_INPUT_WEIGHT + EMPTY_SCRIPT_SIG_WEIGHT + P2TR_KEY_PATH_WITNESS_WEIGHT; + +/// Lower bound for unknown segwit version input weight is chosen the same as P2WPKH in BOLT 3 +pub(crate) const UNKNOWN_SEGWIT_VERSION_INPUT_WEIGHT_LOWER_BOUND: u64 = + P2WPKH_INPUT_WEIGHT_LOWER_BOUND; + trait SerialIdExt { fn is_for_initiator(&self) -> bool; fn is_for_non_initiator(&self) -> bool; @@ -54,7 +80,7 @@ impl SerialIdExt for SerialId { } #[derive(Debug, Clone, PartialEq)] -pub enum AbortReason { +pub(crate) enum AbortReason { InvalidStateTransition, UnexpectedCounterpartyMessage, ReceivedTooManyTxAddInputs, @@ -72,12 +98,86 @@ pub enum AbortReason { InsufficientFees, OutputsValueExceedsInputsValue, InvalidTx, + /// No funding (shared) output found. + MissingFundingOutput, + /// More than one funding (shared) output found. + DuplicateFundingOutput, + /// The intended local part of the funding output is higher than the actual shared funding output, + /// 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, } -#[derive(Debug)] -pub struct TxInputWithPrevOutput { - input: TxIn, - prev_output: TxOut, +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ConstructedTransaction { + holder_is_initiator: bool, + + inputs: Vec, + outputs: Vec, + + local_inputs_value_satoshis: u64, + local_outputs_value_satoshis: u64, + + remote_inputs_value_satoshis: u64, + remote_outputs_value_satoshis: u64, + + lock_time: AbsoluteLockTime, +} + +impl ConstructedTransaction { + fn new(context: NegotiationContext) -> Self { + let local_inputs_value_satoshis = context + .inputs + .iter() + .fold(0u64, |value, (_, input)| value.saturating_add(input.local_value())); + + let local_outputs_value_satoshis = context + .outputs + .iter() + .fold(0u64, |value, (_, output)| value.saturating_add(output.local_value())); + + Self { + holder_is_initiator: context.holder_is_initiator, + + local_inputs_value_satoshis, + local_outputs_value_satoshis, + + remote_inputs_value_satoshis: context.remote_inputs_value(), + remote_outputs_value_satoshis: context.remote_outputs_value(), + + inputs: context.inputs.into_values().collect(), + outputs: context.outputs.into_values().collect(), + + lock_time: context.tx_locktime, + } + } + + pub fn weight(&self) -> Weight { + let inputs_weight = self.inputs.iter().fold(Weight::from_wu(0), |weight, input| { + weight.checked_add(estimate_input_weight(input.prev_output())).unwrap_or(Weight::MAX) + }); + let outputs_weight = self.outputs.iter().fold(Weight::from_wu(0), |weight, output| { + weight.checked_add(get_output_weight(&output.script_pubkey())).unwrap_or(Weight::MAX) + }); + Weight::from_wu(TX_COMMON_FIELDS_WEIGHT) + .checked_add(inputs_weight) + .and_then(|weight| weight.checked_add(outputs_weight)) + .unwrap_or(Weight::MAX) + } + + pub fn into_unsigned_tx(self) -> Transaction { + // Inputs and outputs must be sorted by serial_id + let ConstructedTransaction { mut inputs, mut outputs, .. } = self; + + inputs.sort_unstable_by_key(|input| input.serial_id()); + outputs.sort_unstable_by_key(|output| output.serial_id); + + let input: Vec = inputs.into_iter().map(|input| input.txin().clone()).collect(); + let output: Vec = + outputs.into_iter().map(|output| output.tx_out().clone()).collect(); + + Transaction { version: Version::TWO, lock_time: self.lock_time, input, output } + } } #[derive(Debug)] @@ -85,38 +185,121 @@ struct NegotiationContext { holder_is_initiator: bool, received_tx_add_input_count: u16, received_tx_add_output_count: u16, - inputs: HashMap, + inputs: HashMap, + /// The output script intended to be the new funding output script. + /// The script pubkey is used to determine which output is the funding output. + /// When an output with the same script pubkey is added by any of the nodes, it will be + /// treated as the shared output. + /// The value is the holder's intended contribution to the shared funding output. + /// The rest is the counterparty's contribution. + /// When the funding output is added (recognized by its output script pubkey), it will be marked + /// as shared, and split between the peers according to the local value. + /// If the local value is found to be larger than the actual funding output, an error is generated. + expected_shared_funding_output: (ScriptBuf, u64), + /// The actual new funding output, set only after the output has actually been added. + /// NOTE: this output is also included in `outputs`. + actual_new_funding_output: Option, prevtx_outpoints: HashSet, - outputs: HashMap, + /// The outputs added so far. + outputs: HashMap, + /// The locktime of the funding transaction. tx_locktime: AbsoluteLockTime, + /// The fee rate used for the transaction feerate_sat_per_kw: u32, - to_remote_value_satoshis: u64, +} + +pub(crate) fn estimate_input_weight(prev_output: &TxOut) -> Weight { + Weight::from_wu(if prev_output.script_pubkey.is_p2wpkh() { + P2WPKH_INPUT_WEIGHT_LOWER_BOUND + } else if prev_output.script_pubkey.is_p2wsh() { + P2WSH_INPUT_WEIGHT_LOWER_BOUND + } else if prev_output.script_pubkey.is_p2tr() { + P2TR_INPUT_WEIGHT_LOWER_BOUND + } else { + UNKNOWN_SEGWIT_VERSION_INPUT_WEIGHT_LOWER_BOUND + }) +} + +pub(crate) fn get_output_weight(script_pubkey: &ScriptBuf) -> Weight { + Weight::from_wu( + (8 /* value */ + script_pubkey.consensus_encode(&mut sink()).unwrap() as u64) + * WITNESS_SCALE_FACTOR as u64, + ) +} + +fn is_serial_id_valid_for_counterparty(holder_is_initiator: bool, serial_id: &SerialId) -> bool { + // A received `SerialId`'s parity must match the role of the counterparty. + holder_is_initiator == serial_id.is_for_non_initiator() } impl NegotiationContext { + fn new( + holder_is_initiator: bool, expected_shared_funding_output: (ScriptBuf, u64), + tx_locktime: AbsoluteLockTime, feerate_sat_per_kw: u32, + ) -> Self { + NegotiationContext { + holder_is_initiator, + received_tx_add_input_count: 0, + received_tx_add_output_count: 0, + inputs: new_hash_map(), + expected_shared_funding_output, + actual_new_funding_output: None, + prevtx_outpoints: new_hash_set(), + outputs: new_hash_map(), + tx_locktime, + feerate_sat_per_kw, + } + } + + fn set_actual_new_funding_output( + &mut self, tx_out: TxOut, + ) -> Result { + if self.actual_new_funding_output.is_some() { + return Err(AbortReason::DuplicateFundingOutput); + } + let value = tx_out.value.to_sat(); + let local_owned = self.expected_shared_funding_output.1; + // Sanity check + if local_owned > value { + return Err(AbortReason::InvalidLowFundingOutputValue); + } + let shared_output = SharedOwnedOutput::new(tx_out, local_owned); + self.actual_new_funding_output = Some(shared_output.clone()); + Ok(shared_output) + } + fn is_serial_id_valid_for_counterparty(&self, serial_id: &SerialId) -> bool { - // A received `SerialId`'s parity must match the role of the counterparty. - self.holder_is_initiator == serial_id.is_for_non_initiator() + is_serial_id_valid_for_counterparty(self.holder_is_initiator, serial_id) } - fn total_input_and_output_count(&self) -> usize { - self.inputs.len().saturating_add(self.outputs.len()) + fn remote_inputs_value(&self) -> u64 { + self.inputs.iter().fold(0u64, |acc, (_, input)| acc.saturating_add(input.remote_value())) } - fn counterparty_inputs_contributed( - &self, - ) -> impl Iterator + Clone { - self.inputs - .iter() - .filter(move |(serial_id, _)| self.is_serial_id_valid_for_counterparty(serial_id)) - .map(|(_, input_with_prevout)| input_with_prevout) + fn remote_outputs_value(&self) -> u64 { + self.outputs.iter().fold(0u64, |acc, (_, output)| acc.saturating_add(output.remote_value())) } - fn counterparty_outputs_contributed(&self) -> impl Iterator + Clone { - self.outputs - .iter() - .filter(move |(serial_id, _)| self.is_serial_id_valid_for_counterparty(serial_id)) - .map(|(_, output)| output) + fn remote_inputs_weight(&self) -> Weight { + Weight::from_wu( + self.inputs + .iter() + .filter(|(serial_id, _)| self.is_serial_id_valid_for_counterparty(serial_id)) + .fold(0u64, |weight, (_, input)| { + weight.saturating_add(estimate_input_weight(input.prev_output()).to_wu()) + }), + ) + } + + fn remote_outputs_weight(&self) -> Weight { + Weight::from_wu( + self.outputs + .iter() + .filter(|(serial_id, _)| self.is_serial_id_valid_for_counterparty(serial_id)) + .fold(0u64, |weight, (_, output)| { + weight.saturating_add(get_output_weight(&output.script_pubkey()).to_wu()) + }), + ) } fn received_tx_add_input(&mut self, msg: &msgs::TxAddInput) -> Result<(), AbortReason> { @@ -177,23 +360,28 @@ impl NegotiationContext { } else { return Err(AbortReason::PrevTxOutInvalid); }; - if self.inputs.iter().any(|(serial_id, _)| *serial_id == msg.serial_id) { - // The receiving node: - // - MUST fail the negotiation if: - // - the `serial_id` is already included in the transaction - return Err(AbortReason::DuplicateSerialId); - } - let prev_outpoint = OutPoint { txid, vout: msg.prevtx_out }; - self.inputs.entry(msg.serial_id).or_insert_with(|| TxInputWithPrevOutput { - input: TxIn { - previous_output: prev_outpoint.clone(), - sequence: Sequence(msg.sequence), - ..Default::default() + match self.inputs.entry(msg.serial_id) { + hash_map::Entry::Occupied(_) => { + // The receiving node: + // - MUST fail the negotiation if: + // - the `serial_id` is already included in the transaction + Err(AbortReason::DuplicateSerialId) }, - prev_output: prev_out, - }); - self.prevtx_outpoints.insert(prev_outpoint); - Ok(()) + hash_map::Entry::Vacant(entry) => { + let prev_outpoint = OutPoint { txid, vout: msg.prevtx_out }; + entry.insert(InteractiveTxInput::Remote(LocalOrRemoteInput { + serial_id: msg.serial_id, + input: TxIn { + previous_output: prev_outpoint, + sequence: Sequence(msg.sequence), + ..Default::default() + }, + prev_output: prev_out, + })); + self.prevtx_outpoints.insert(prev_outpoint); + Ok(()) + }, + } } fn received_tx_remove_input(&mut self, msg: &msgs::TxRemoveInput) -> Result<(), AbortReason> { @@ -238,7 +426,7 @@ impl NegotiationContext { // bitcoin supply. let mut outputs_value: u64 = 0; for output in self.outputs.iter() { - outputs_value = outputs_value.saturating_add(output.1.value); + outputs_value = outputs_value.saturating_add(output.1.value()); } if outputs_value.saturating_add(msg.sats) > TOTAL_BITCOIN_SUPPLY_SATOSHIS { // The receiving node: @@ -256,30 +444,53 @@ impl NegotiationContext { // with witness versions V1 and up are always considered standard. Yes, the scripts can be // anyone-can-spend-able, but if our counterparty wants to add an output like that then it's none // of our concern really ¯\_(ツ)_/¯ - if !msg.script.is_v0_p2wpkh() - && !msg.script.is_v0_p2wsh() - && msg.script.witness_version().map(|v| v.to_num() < 1).unwrap_or(true) + // + // TODO: The last check would be simplified when https://github.com/rust-bitcoin/rust-bitcoin/commit/1656e1a09a1959230e20af90d20789a4a8f0a31b + // hits the next release of rust-bitcoin. + if !(msg.script.is_p2wpkh() + || msg.script.is_p2wsh() + || (msg.script.is_witness_program() + && msg.script.witness_version().map(|v| v.to_num() >= 1).unwrap_or(false))) { return Err(AbortReason::InvalidOutputScript); } - if self.outputs.iter().any(|(serial_id, _)| *serial_id == msg.serial_id) { - // The receiving node: - // - MUST fail the negotiation if: - // - the `serial_id` is already included in the transaction - return Err(AbortReason::DuplicateSerialId); + let txout = TxOut { value: Amount::from_sat(msg.sats), script_pubkey: msg.script.clone() }; + let is_shared = msg.script == self.expected_shared_funding_output.0; + let output = if is_shared { + // this is a shared funding output + let shared_output = self.set_actual_new_funding_output(txout)?; + InteractiveTxOutput { + serial_id: msg.serial_id, + added_by: AddingRole::Remote, + output: OutputOwned::Shared(shared_output), + } + } else { + InteractiveTxOutput { + serial_id: msg.serial_id, + added_by: AddingRole::Remote, + output: OutputOwned::Single(txout), + } + }; + match self.outputs.entry(msg.serial_id) { + hash_map::Entry::Occupied(_) => { + // The receiving node: + // - MUST fail the negotiation if: + // - the `serial_id` is already included in the transaction + Err(AbortReason::DuplicateSerialId) + }, + hash_map::Entry::Vacant(entry) => { + entry.insert(output); + Ok(()) + }, } - - let output = TxOut { value: msg.sats, script_pubkey: msg.script.clone() }; - self.outputs.entry(msg.serial_id).or_insert(output); - Ok(()) } fn received_tx_remove_output(&mut self, msg: &msgs::TxRemoveOutput) -> Result<(), AbortReason> { if !self.is_serial_id_valid_for_counterparty(&msg.serial_id) { return Err(AbortReason::IncorrectSerialIdParity); } - if let Some(_) = self.outputs.remove(&msg.serial_id) { + if self.outputs.remove(&msg.serial_id).is_some() { Ok(()) } else { // The receiving node: @@ -290,54 +501,91 @@ impl NegotiationContext { } } - fn sent_tx_add_input(&mut self, msg: &msgs::TxAddInput) { + fn sent_tx_add_input(&mut self, msg: &msgs::TxAddInput) -> Result<(), AbortReason> { let tx = msg.prevtx.as_transaction(); - let input = TxIn { + let txin = TxIn { previous_output: OutPoint { txid: tx.txid(), vout: msg.prevtx_out }, sequence: Sequence(msg.sequence), ..Default::default() }; - debug_assert!((msg.prevtx_out as usize) < tx.output.len()); - let prev_output = &tx.output[msg.prevtx_out as usize]; - self.prevtx_outpoints.insert(input.previous_output.clone()); - self.inputs.insert( - msg.serial_id, - TxInputWithPrevOutput { input, prev_output: prev_output.clone() }, - ); + if !self.prevtx_outpoints.insert(txin.previous_output.clone()) { + // We have added an input that already exists + return Err(AbortReason::PrevTxOutInvalid); + } + let vout = txin.previous_output.vout as usize; + let prev_output = tx.output.get(vout).ok_or(AbortReason::PrevTxOutInvalid)?.clone(); + let input = InteractiveTxInput::Local(LocalOrRemoteInput { + serial_id: msg.serial_id, + input: txin, + prev_output, + }); + self.inputs.insert(msg.serial_id, input); + Ok(()) } - fn sent_tx_add_output(&mut self, msg: &msgs::TxAddOutput) { - self.outputs - .insert(msg.serial_id, TxOut { value: msg.sats, script_pubkey: msg.script.clone() }); + fn sent_tx_add_output(&mut self, msg: &msgs::TxAddOutput) -> Result<(), AbortReason> { + let txout = TxOut { value: Amount::from_sat(msg.sats), script_pubkey: msg.script.clone() }; + let is_shared = msg.script == self.expected_shared_funding_output.0; + let output = if is_shared { + // this is a shared funding output + let shared_output = self.set_actual_new_funding_output(txout)?; + InteractiveTxOutput { + serial_id: msg.serial_id, + added_by: AddingRole::Local, + output: OutputOwned::Shared(shared_output), + } + } else { + InteractiveTxOutput { + serial_id: msg.serial_id, + added_by: AddingRole::Local, + output: OutputOwned::Single(txout), + } + }; + self.outputs.insert(msg.serial_id, output); + Ok(()) } - fn sent_tx_remove_input(&mut self, msg: &msgs::TxRemoveInput) { + fn sent_tx_remove_input(&mut self, msg: &msgs::TxRemoveInput) -> Result<(), AbortReason> { self.inputs.remove(&msg.serial_id); + Ok(()) } - fn sent_tx_remove_output(&mut self, msg: &msgs::TxRemoveOutput) { + fn sent_tx_remove_output(&mut self, msg: &msgs::TxRemoveOutput) -> Result<(), AbortReason> { self.outputs.remove(&msg.serial_id); + Ok(()) } - fn build_transaction(self) -> Result { + fn check_counterparty_fees( + &self, counterparty_fees_contributed: u64, + ) -> Result<(), AbortReason> { + let counterparty_weight_contributed = self + .remote_inputs_weight() + .to_wu() + .saturating_add(self.remote_outputs_weight().to_wu()); + let mut required_counterparty_contribution_fee = + fee_for_weight(self.feerate_sat_per_kw, counterparty_weight_contributed); + if !self.holder_is_initiator { + // if is the non-initiator: + // - the initiator's fees do not cover the common fields (version, segwit marker + flag, + // input count, output count, locktime) + let tx_common_fields_fee = + fee_for_weight(self.feerate_sat_per_kw, TX_COMMON_FIELDS_WEIGHT); + required_counterparty_contribution_fee += tx_common_fields_fee; + } + if counterparty_fees_contributed < required_counterparty_contribution_fee { + return Err(AbortReason::InsufficientFees); + } + Ok(()) + } + + fn validate_tx(self) -> Result { // The receiving node: // MUST fail the negotiation if: // - the peer's total input satoshis is less than their outputs - let mut counterparty_inputs_value: u64 = 0; - let mut counterparty_outputs_value: u64 = 0; - for input in self.counterparty_inputs_contributed() { - counterparty_inputs_value = - counterparty_inputs_value.saturating_add(input.prev_output.value); - } - for output in self.counterparty_outputs_contributed() { - counterparty_outputs_value = counterparty_outputs_value.saturating_add(output.value); - } - // ...actually the counterparty might be splicing out, so that their balance also contributes - // to the total input value. - if counterparty_inputs_value.saturating_add(self.to_remote_value_satoshis) - < counterparty_outputs_value - { + let remote_inputs_value = self.remote_inputs_value(); + let remote_outputs_value = self.remote_outputs_value(); + if remote_inputs_value < remote_outputs_value { return Err(AbortReason::OutputsValueExceedsInputsValue); } @@ -349,55 +597,20 @@ impl NegotiationContext { return Err(AbortReason::ExceededNumberOfInputsOrOutputs); } - // TODO: How do we enforce their fees cover the witness without knowing its expected length? - const INPUT_WEIGHT: u64 = BASE_INPUT_WEIGHT + EMPTY_SCRIPT_SIG_WEIGHT; + if self.actual_new_funding_output.is_none() { + return Err(AbortReason::MissingFundingOutput); + } // - the peer's paid feerate does not meet or exceed the agreed feerate (based on the minimum fee). - let counterparty_output_weight_contributed: u64 = self - .counterparty_outputs_contributed() - .map(|output| { - (8 /* value */ + output.script_pubkey.consensus_encode(&mut sink()).unwrap() as u64) - * WITNESS_SCALE_FACTOR as u64 - }) - .sum(); - let counterparty_weight_contributed = counterparty_output_weight_contributed - + self.counterparty_inputs_contributed().count() as u64 * INPUT_WEIGHT; - let counterparty_fees_contributed = - counterparty_inputs_value.saturating_sub(counterparty_outputs_value); - let mut required_counterparty_contribution_fee = - fee_for_weight(self.feerate_sat_per_kw, counterparty_weight_contributed); - if !self.holder_is_initiator { - // if is the non-initiator: - // - the initiator's fees do not cover the common fields (version, segwit marker + flag, - // input count, output count, locktime) - let tx_common_fields_weight = - (4 /* version */ + 4 /* locktime */ + 1 /* input count */ + 1 /* output count */) * - WITNESS_SCALE_FACTOR as u64 + 2 /* segwit marker + flag */; - let tx_common_fields_fee = - fee_for_weight(self.feerate_sat_per_kw, tx_common_fields_weight); - required_counterparty_contribution_fee += tx_common_fields_fee; - } - if counterparty_fees_contributed < required_counterparty_contribution_fee { - return Err(AbortReason::InsufficientFees); - } + self.check_counterparty_fees(remote_inputs_value.saturating_sub(remote_outputs_value))?; - // Inputs and outputs must be sorted by serial_id - let mut inputs = self.inputs.into_iter().collect::>(); - let mut outputs = self.outputs.into_iter().collect::>(); - inputs.sort_unstable_by_key(|(serial_id, _)| *serial_id); - outputs.sort_unstable_by_key(|(serial_id, _)| *serial_id); - - let tx_to_validate = Transaction { - version: 2, - lock_time: self.tx_locktime, - input: inputs.into_iter().map(|(_, input)| input.input).collect(), - output: outputs.into_iter().map(|(_, output)| output).collect(), - }; - if tx_to_validate.weight().to_wu() > MAX_STANDARD_TX_WEIGHT as u64 { + let constructed_tx = ConstructedTransaction::new(self); + + if constructed_tx.weight().to_wu() > MAX_STANDARD_TX_WEIGHT as u64 { return Err(AbortReason::TransactionTooLarge); } - Ok(tx_to_validate) + Ok(constructed_tx) } } @@ -485,7 +698,7 @@ define_state!( ReceivedTxComplete, "We have received a `tx_complete` message and the counterparty is awaiting ours." ); -define_state!(NegotiationComplete, Transaction, "We have exchanged consecutive `tx_complete` messages with the counterparty and the transaction negotiation is complete."); +define_state!(NegotiationComplete, ConstructedTransaction, "We have exchanged consecutive `tx_complete` messages with the counterparty and the transaction negotiation is complete."); define_state!( NegotiationAborted, AbortReason, @@ -517,7 +730,7 @@ macro_rules! define_state_transitions { impl StateTransition for S { fn transition(self, data: $data) -> StateTransitionResult { let mut context = self.into_negotiation_context(); - context.$transition(data); + context.$transition(data)?; Ok(SentChangeMsg(context)) } } @@ -527,7 +740,7 @@ macro_rules! define_state_transitions { impl StateTransition for $tx_complete_state { fn transition(self, _data: &msgs::TxComplete) -> StateTransitionResult { let context = self.into_negotiation_context(); - let tx = context.build_transaction()?; + let tx = context.validate_tx()?; Ok(NegotiationComplete(tx)) } } @@ -598,19 +811,14 @@ macro_rules! define_state_machine_transitions { impl StateMachine { fn new( feerate_sat_per_kw: u32, is_initiator: bool, tx_locktime: AbsoluteLockTime, - to_remote_value_satoshis: u64, + expected_shared_funding_output: (ScriptBuf, u64), ) -> Self { - let context = NegotiationContext { + let context = NegotiationContext::new( + is_initiator, + expected_shared_funding_output, tx_locktime, - holder_is_initiator: is_initiator, - received_tx_add_input_count: 0, - received_tx_add_output_count: 0, - inputs: new_hash_map(), - prevtx_outpoints: new_hash_set(), - outputs: new_hash_map(), feerate_sat_per_kw, - to_remote_value_satoshis, - }; + ); if is_initiator { Self::ReceivedChangeMsg(ReceivedChangeMsg(context)) } else { @@ -669,14 +877,185 @@ impl StateMachine { ]); } -pub struct InteractiveTxConstructor { +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AddingRole { + Local, + Remote, +} + +/// Represents an input -- local or remote (both have the same fields) +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LocalOrRemoteInput { + serial_id: SerialId, + input: TxIn, + prev_output: TxOut, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum InteractiveTxInput { + Local(LocalOrRemoteInput), + Remote(LocalOrRemoteInput), + // TODO(splicing) SharedInput should be added +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SharedOwnedOutput { + tx_out: TxOut, + local_owned: u64, +} + +impl SharedOwnedOutput { + 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 {}", + local_owned, + tx_out.value + ); + SharedOwnedOutput { tx_out, local_owned } + } + + fn remote_owned(&self) -> u64 { + self.tx_out.value.to_sat().saturating_sub(self.local_owned) + } +} + +/// Represents an output, with information about +/// 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 { + /// Belongs to local node -- controlled exclusively and fully belonging to local node + Single(TxOut), + /// Output with shared control, but fully belonging to local node + SharedControlFullyOwned(TxOut), + /// Output with shared control and joint ownership + Shared(SharedOwnedOutput), +} + +impl OutputOwned { + fn tx_out(&self) -> &TxOut { + match self { + OutputOwned::Single(tx_out) | OutputOwned::SharedControlFullyOwned(tx_out) => tx_out, + OutputOwned::Shared(output) => &output.tx_out, + } + } + + fn value(&self) -> u64 { + self.tx_out().value.to_sat() + } + + 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::Shared(output) => output.local_owned, + } + } + + 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::Shared(output) => output.remote_owned(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InteractiveTxOutput { + serial_id: SerialId, + added_by: AddingRole, + output: OutputOwned, +} + +impl InteractiveTxOutput { + fn tx_out(&self) -> &TxOut { + self.output.tx_out() + } + + fn value(&self) -> u64 { + self.tx_out().value.to_sat() + } + + fn local_value(&self) -> u64 { + self.output.local_value(self.added_by) + } + + fn remote_value(&self) -> u64 { + self.output.remote_value(self.added_by) + } + + fn script_pubkey(&self) -> &ScriptBuf { + &self.output.tx_out().script_pubkey + } +} + +impl InteractiveTxInput { + pub fn serial_id(&self) -> SerialId { + match self { + InteractiveTxInput::Local(input) => input.serial_id, + InteractiveTxInput::Remote(input) => input.serial_id, + } + } + + pub fn txin(&self) -> &TxIn { + match self { + InteractiveTxInput::Local(input) => &input.input, + InteractiveTxInput::Remote(input) => &input.input, + } + } + + pub fn prev_output(&self) -> &TxOut { + match self { + InteractiveTxInput::Local(input) => &input.prev_output, + InteractiveTxInput::Remote(input) => &input.prev_output, + } + } + + pub fn value(&self) -> u64 { + self.prev_output().value.to_sat() + } + + pub fn local_value(&self) -> u64 { + match self { + InteractiveTxInput::Local(input) => input.prev_output.value.to_sat(), + InteractiveTxInput::Remote(_input) => 0, + } + } + + pub fn remote_value(&self) -> u64 { + match self { + InteractiveTxInput::Local(_input) => 0, + InteractiveTxInput::Remote(input) => input.prev_output.value.to_sat(), + } + } +} + +pub(crate) struct InteractiveTxConstructor { state_machine: StateMachine, channel_id: ChannelId, inputs_to_contribute: Vec<(SerialId, TxIn, TransactionU16LenLimited)>, - outputs_to_contribute: Vec<(SerialId, TxOut)>, + outputs_to_contribute: Vec<(SerialId, OutputOwned)>, } -pub enum InteractiveTxMessageSend { +pub(crate) enum InteractiveTxMessageSend { TxAddInput(msgs::TxAddInput), TxAddOutput(msgs::TxAddOutput), TxComplete(msgs::TxComplete), @@ -708,17 +1087,22 @@ where serial_id } -pub enum HandleTxCompleteValue { +pub(crate) enum HandleTxCompleteValue { SendTxMessage(InteractiveTxMessageSend), - SendTxComplete(InteractiveTxMessageSend, Transaction), - NegotiationComplete(Transaction), + SendTxComplete(InteractiveTxMessageSend, ConstructedTransaction), + NegotiationComplete(ConstructedTransaction), } impl InteractiveTxConstructor { /// Instantiates a new `InteractiveTxConstructor`. /// - /// If this is for a dual_funded channel then the `to_remote_value_satoshis` parameter should be set - /// to zero. + /// `expected_remote_shared_funding_output`: In the case when the local node doesn't + /// add a shared output, but it expects a shared output to be added by the remote node, + /// it has to specify the script pubkey, used to determine the shared output, + /// and its (local) contribution from the shared output: + /// 0 when the whole value belongs to the remote node, or + /// positive if owned also by local. + /// Note: The local value cannot be larger that the actual shared output. /// /// A tuple is returned containing the newly instantiate `InteractiveTxConstructor` and optionally /// an initial wrapped `Tx_` message which the holder needs to send to the counterparty. @@ -726,55 +1110,90 @@ impl InteractiveTxConstructor { entropy_source: &ES, channel_id: ChannelId, feerate_sat_per_kw: u32, is_initiator: bool, funding_tx_locktime: AbsoluteLockTime, inputs_to_contribute: Vec<(TxIn, TransactionU16LenLimited)>, - outputs_to_contribute: Vec, to_remote_value_satoshis: u64, - ) -> (Self, Option) + outputs_to_contribute: Vec, + expected_remote_shared_funding_output: Option<(ScriptBuf, u64)>, + ) -> Result<(Self, Option), AbortReason> where ES::Target: EntropySource, { - let state_machine = StateMachine::new( - feerate_sat_per_kw, - is_initiator, - funding_tx_locktime, - to_remote_value_satoshis, - ); - let mut inputs_to_contribute: Vec<(SerialId, TxIn, TransactionU16LenLimited)> = - inputs_to_contribute + // Sanity check: There can be at most one shared output, local-added or remote-added + let mut expected_shared_funding_output: Option<(ScriptBuf, u64)> = None; + 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() { + return Err(AbortReason::InvalidLowFundingOutputValue); + } + Some((output.tx_out.script_pubkey.clone(), output.local_owned)) + }, + }; + if new_output.is_some() { + if expected_shared_funding_output.is_some() + || expected_remote_shared_funding_output.is_some() + { + // more than one local-added shared output or + // one local-added and one remote-expected shared output + return Err(AbortReason::DuplicateFundingOutput); + } + expected_shared_funding_output = new_output; + } + } + if let Some(expected_remote_shared_funding_output) = expected_remote_shared_funding_output { + expected_shared_funding_output = Some(expected_remote_shared_funding_output); + } + if let Some(expected_shared_funding_output) = expected_shared_funding_output { + let state_machine = StateMachine::new( + feerate_sat_per_kw, + is_initiator, + funding_tx_locktime, + expected_shared_funding_output, + ); + let mut inputs_to_contribute: Vec<(SerialId, TxIn, TransactionU16LenLimited)> = + inputs_to_contribute + .into_iter() + .map(|(input, tx)| { + let serial_id = generate_holder_serial_id(entropy_source, is_initiator); + (serial_id, input, tx) + }) + .collect(); + // We'll sort by the randomly generated serial IDs, effectively shuffling the order of the inputs + // as the user passed them to us to avoid leaking any potential categorization of transactions + // before we pass any of the inputs to the counterparty. + inputs_to_contribute.sort_unstable_by_key(|(serial_id, _, _)| *serial_id); + let mut outputs_to_contribute: Vec<_> = outputs_to_contribute .into_iter() - .map(|(input, tx)| { + .map(|output| { let serial_id = generate_holder_serial_id(entropy_source, is_initiator); - (serial_id, input, tx) + (serial_id, output) }) .collect(); - // We'll sort by the randomly generated serial IDs, effectively shuffling the order of the inputs - // as the user passed them to us to avoid leaking any potential categorization of transactions - // before we pass any of the inputs to the counterparty. - inputs_to_contribute.sort_unstable_by_key(|(serial_id, _, _)| *serial_id); - let mut outputs_to_contribute: Vec<(SerialId, TxOut)> = outputs_to_contribute - .into_iter() - .map(|output| { - let serial_id = generate_holder_serial_id(entropy_source, is_initiator); - (serial_id, output) - }) - .collect(); - // In the same manner and for the same rationale as the inputs above, we'll shuffle the outputs. - outputs_to_contribute.sort_unstable_by_key(|(serial_id, _)| *serial_id); - let mut constructor = - Self { state_machine, channel_id, inputs_to_contribute, outputs_to_contribute }; - let message_send = if is_initiator { - match constructor.maybe_send_message() { - Ok(msg_send) => Some(msg_send), - Err(_) => { - debug_assert!( - false, - "We should always be able to start our state machine successfully" - ); - None - }, - } + // In the same manner and for the same rationale as the inputs above, we'll shuffle the outputs. + outputs_to_contribute.sort_unstable_by_key(|(serial_id, _)| *serial_id); + let mut constructor = + Self { state_machine, channel_id, inputs_to_contribute, outputs_to_contribute }; + let message_send = if is_initiator { + match constructor.maybe_send_message() { + Ok(msg_send) => Some(msg_send), + Err(_) => { + debug_assert!( + false, + "We should always be able to start our state machine successfully" + ); + None + }, + } + } else { + None + }; + Ok((constructor, message_send)) } else { - None - }; - (constructor, message_send) + Err(AbortReason::MissingFundingOutput) + } } fn maybe_send_message(&mut self) -> Result { @@ -787,6 +1206,7 @@ impl InteractiveTxConstructor { prevtx, prevtx_out: input.previous_output.vout, sequence: input.sequence.to_consensus_u32(), + shared_input_txid: None, }; do_state_transition!(self, sent_tx_add_input, &msg)?; Ok(InteractiveTxMessageSend::TxAddInput(msg)) @@ -794,8 +1214,8 @@ impl InteractiveTxConstructor { let msg = msgs::TxAddOutput { channel_id: self.channel_id, serial_id, - sats: output.value, - script: output.script_pubkey, + sats: output.tx_out().value.to_sat(), + script: output.tx_out().script_pubkey.clone(), }; do_state_transition!(self, sent_tx_add_output, &msg)?; Ok(InteractiveTxMessageSend::TxAddOutput(msg)) @@ -841,7 +1261,7 @@ impl InteractiveTxConstructor { match &self.state_machine { StateMachine::ReceivedTxComplete(_) => { let msg_send = self.maybe_send_message()?; - return match &self.state_machine { + match &self.state_machine { StateMachine::NegotiationComplete(s) => { Ok(HandleTxCompleteValue::SendTxComplete(msg_send, s.0.clone())) }, @@ -850,9 +1270,9 @@ impl InteractiveTxConstructor { }, // We either had an input or output to contribute. _ => { debug_assert!(false, "We cannot transition to any other states after receiving `tx_complete` and responding"); - return Err(AbortReason::InvalidStateTransition); + Err(AbortReason::InvalidStateTransition) }, - }; + } }, StateMachine::NegotiationComplete(s) => { Ok(HandleTxCompleteValue::NegotiationComplete(s.0.clone())) @@ -870,24 +1290,38 @@ impl InteractiveTxConstructor { #[cfg(test)] mod tests { - use crate::chain::chaininterface::FEERATE_FLOOR_SATS_PER_KW; + 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, InteractiveTxMessageSend, MAX_INPUTS_OUTPUTS_COUNT, MAX_RECEIVED_TX_ADD_INPUT_COUNT, MAX_RECEIVED_TX_ADD_OUTPUT_COUNT, }; - use crate::ln::ChannelId; + use crate::ln::types::ChannelId; use crate::sign::EntropySource; use crate::util::atomic_counter::AtomicCounter; use crate::util::ser::TransactionU16LenLimited; + use bitcoin::absolute::LockTime as AbsoluteLockTime; + use bitcoin::amount::Amount; use bitcoin::blockdata::opcodes; use bitcoin::blockdata::script::Builder; + use bitcoin::hashes::Hash; + use bitcoin::key::UntweakedPublicKey; + use bitcoin::secp256k1::{Keypair, Secp256k1}; + use bitcoin::transaction::Version; use bitcoin::{ - absolute::LockTime as AbsoluteLockTime, OutPoint, Sequence, Transaction, TxIn, TxOut, + OutPoint, PubkeyHash, ScriptBuf, Sequence, Transaction, TxIn, TxOut, WPubkeyHash, }; use core::ops::Deref; + use super::{ + get_output_weight, AddingRole, OutputOwned, SharedOwnedOutput, + P2TR_INPUT_WEIGHT_LOWER_BOUND, P2WPKH_INPUT_WEIGHT_LOWER_BOUND, + P2WSH_INPUT_WEIGHT_LOWER_BOUND, TX_COMMON_FIELDS_WEIGHT, + }; + + const TEST_FEERATE_SATS_PER_KW: u32 = FEERATE_FLOOR_SATS_PER_KW * 10; + // A simple entropy source that works based on an atomic counter. struct TestEntropySource(AtomicCounter); impl EntropySource for TestEntropySource { @@ -929,11 +1363,16 @@ mod tests { } struct TestSession { + description: &'static str, inputs_a: Vec<(TxIn, TransactionU16LenLimited)>, - outputs_a: Vec, + outputs_a: Vec, inputs_b: Vec<(TxIn, TransactionU16LenLimited)>, - outputs_b: Vec, + outputs_b: Vec, expect_error: Option<(AbortReason, ErrorCulprit)>, + /// A node adds no shared output, but expects the peer to add one, with the specific script pubkey, and local contribution + a_expected_remote_shared_output: Option<(ScriptBuf, u64)>, + /// B node adds no shared output, but expects the peer to add one, with the specific script pubkey, and local contribution + b_expected_remote_shared_output: Option<(ScriptBuf, u64)>, } fn do_test_interactive_tx_constructor(session: TestSession) { @@ -957,26 +1396,103 @@ mod tests { let channel_id = ChannelId(entropy_source.get_secure_random_bytes()); let tx_locktime = AbsoluteLockTime::from_height(1337).unwrap(); - let (mut constructor_a, first_message_a) = InteractiveTxConstructor::new( + // funding output sanity check + let shared_outputs_by_a: Vec<_> = + session.outputs_a.iter().filter(|o| o.is_shared()).collect(); + if shared_outputs_by_a.len() > 1 { + println!("Test warning: Expected at most one shared output. NodeA"); + } + let shared_output_by_a = if shared_outputs_by_a.len() >= 1 { + Some(shared_outputs_by_a[0].value()) + } else { + None + }; + let shared_outputs_by_b: Vec<_> = + session.outputs_b.iter().filter(|o| o.is_shared()).collect(); + if shared_outputs_by_b.len() > 1 { + println!("Test warning: Expected at most one shared output. NodeB"); + } + let shared_output_by_b = if shared_outputs_by_b.len() >= 1 { + Some(shared_outputs_by_b[0].value()) + } else { + None + }; + if session.a_expected_remote_shared_output.is_some() + || session.b_expected_remote_shared_output.is_some() + { + let expected_by_a = if let Some(a_expected_remote_shared_output) = + &session.a_expected_remote_shared_output + { + a_expected_remote_shared_output.1 + } else { + if shared_outputs_by_a.len() >= 1 { + shared_outputs_by_a[0].local_value(AddingRole::Local) + } else { + 0 + } + }; + let expected_by_b = if let Some(b_expected_remote_shared_output) = + &session.b_expected_remote_shared_output + { + b_expected_remote_shared_output.1 + } else { + if shared_outputs_by_b.len() >= 1 { + shared_outputs_by_b[0].local_value(AddingRole::Local) + } else { + 0 + } + }; + + let expected_sum = expected_by_a + expected_by_b; + let actual_shared_output = + shared_output_by_a.unwrap_or(shared_output_by_b.unwrap_or(0)); + if expected_sum != actual_shared_output { + println!("Test warning: Sum of expected shared output values does not match actual shared output value, {} {} {} {} {} {}", expected_sum, actual_shared_output, expected_by_a, expected_by_b, shared_output_by_a.unwrap_or(0), shared_output_by_b.unwrap_or(0)); + } + } + + let (mut constructor_a, first_message_a) = match InteractiveTxConstructor::new( entropy_source, channel_id, - FEERATE_FLOOR_SATS_PER_KW * 10, + TEST_FEERATE_SATS_PER_KW, true, tx_locktime, session.inputs_a, - session.outputs_a, - 0, - ); - let (mut constructor_b, first_message_b) = InteractiveTxConstructor::new( + session.outputs_a.iter().map(|o| o.clone()).collect(), + session.a_expected_remote_shared_output, + ) { + Ok(r) => r, + Err(abort_reason) => { + assert_eq!( + Some((abort_reason, ErrorCulprit::NodeA)), + session.expect_error, + "Test: {}", + session.description + ); + return; + }, + }; + let (mut constructor_b, first_message_b) = match InteractiveTxConstructor::new( entropy_source, channel_id, - FEERATE_FLOOR_SATS_PER_KW * 10, + TEST_FEERATE_SATS_PER_KW, false, tx_locktime, session.inputs_b, - session.outputs_b, - 0, - ); + session.outputs_b.iter().map(|o| o.clone()).collect(), + session.b_expected_remote_shared_output, + ) { + Ok(r) => r, + Err(abort_reason) => { + assert_eq!( + Some((abort_reason, ErrorCulprit::NodeB)), + session.expect_error, + "Test: {}", + session.description + ); + return; + }, + }; let handle_message_send = |msg: InteractiveTxMessageSend, for_constructor: &mut InteractiveTxConstructor| { @@ -1020,8 +1536,13 @@ mod tests { }, _ => ErrorCulprit::NodeA, }; - assert_eq!(Some((abort_reason, error_culprit)), session.expect_error); - assert!(message_send_b.is_none()); + assert_eq!( + Some((abort_reason, error_culprit)), + session.expect_error, + "Test: {}", + session.description + ); + assert!(message_send_b.is_none(), "Test: {}", session.description); return; }, } @@ -1039,8 +1560,13 @@ mod tests { }, _ => ErrorCulprit::NodeB, }; - assert_eq!(Some((abort_reason, error_culprit)), session.expect_error); - assert!(message_send_a.is_none()); + assert_eq!( + Some((abort_reason, error_culprit)), + session.expect_error, + "Test: {}", + session.description + ); + assert!(message_send_a.is_none(), "Test: {}", session.description); return; }, } @@ -1048,34 +1574,64 @@ mod tests { } assert!(message_send_a.is_none()); assert!(message_send_b.is_none()); - assert_eq!(final_tx_a, final_tx_b); - assert!(session.expect_error.is_none()); + assert_eq!(final_tx_a.unwrap().into_unsigned_tx(), final_tx_b.unwrap().into_unsigned_tx()); + assert!( + session.expect_error.is_none(), + "Missing expected error {:?}, Test: {}", + session.expect_error, + session.description, + ); + } + + #[derive(Debug, Clone, Copy)] + enum TestOutput { + P2WPKH(u64), + /// P2WSH, but with the specific script used for the funding output + P2WSH(u64), + P2TR(u64), + // Non-witness type to test rejection. + P2PKH(u64), } - fn generate_tx(values: &[u64]) -> Transaction { - generate_tx_with_locktime(values, 1337) + fn generate_tx(outputs: &[TestOutput]) -> Transaction { + generate_tx_with_locktime(outputs, 1337) } - fn generate_tx_with_locktime(values: &[u64], locktime: u32) -> Transaction { + fn generate_txout(output: &TestOutput) -> TxOut { + let secp_ctx = Secp256k1::new(); + let (value, script_pubkey) = match output { + TestOutput::P2WPKH(value) => (*value, generate_p2wpkh_script_pubkey()), + TestOutput::P2WSH(value) => (*value, generate_funding_script_pubkey()), + TestOutput::P2TR(value) => ( + *value, + ScriptBuf::new_p2tr( + &secp_ctx, + UntweakedPublicKey::from_keypair( + &Keypair::from_seckey_slice(&secp_ctx, &[3; 32]).unwrap(), + ) + .0, + None, + ), + ), + TestOutput::P2PKH(value) => { + (*value, ScriptBuf::new_p2pkh(&PubkeyHash::from_slice(&[4; 20]).unwrap())) + }, + }; + + TxOut { value: Amount::from_sat(value), script_pubkey } + } + + fn generate_tx_with_locktime(outputs: &[TestOutput], locktime: u32) -> Transaction { Transaction { - version: 2, + version: Version::TWO, lock_time: AbsoluteLockTime::from_height(locktime).unwrap(), input: vec![TxIn { ..Default::default() }], - output: values - .iter() - .map(|value| TxOut { - value: *value, - script_pubkey: Builder::new() - .push_opcode(opcodes::OP_TRUE) - .into_script() - .to_v0_p2wsh(), - }) - .collect(), + output: outputs.iter().map(generate_txout).collect(), } } - fn generate_inputs(values: &[u64]) -> Vec<(TxIn, TransactionU16LenLimited)> { - let tx = generate_tx(values); + fn generate_inputs(outputs: &[TestOutput]) -> Vec<(TxIn, TransactionU16LenLimited)> { + let tx = generate_tx(outputs); let txid = tx.txid(); tx.output .iter() @@ -1092,17 +1648,47 @@ mod tests { .collect() } - fn generate_outputs(values: &[u64]) -> Vec { - values - .iter() - .map(|value| TxOut { - value: *value, - script_pubkey: Builder::new() - .push_opcode(opcodes::OP_TRUE) - .into_script() - .to_v0_p2wsh(), - }) - .collect() + fn generate_p2wsh_script_pubkey() -> ScriptBuf { + Builder::new().push_opcode(opcodes::OP_TRUE).into_script().to_p2wsh() + } + + fn generate_p2wpkh_script_pubkey() -> ScriptBuf { + ScriptBuf::new_p2wpkh(&WPubkeyHash::from_slice(&[1; 20]).unwrap()) + } + + fn generate_funding_script_pubkey() -> ScriptBuf { + Builder::new().push_int(33).into_script().to_p2wsh() + } + + fn generate_output_nonfunding_one(output: &TestOutput) -> OutputOwned { + OutputOwned::Single(generate_txout(output)) + } + + fn generate_outputs(outputs: &[TestOutput]) -> Vec { + outputs.iter().map(|o| generate_output_nonfunding_one(o)).collect() + } + + /// Generate a single output that is the funding output + fn generate_output(output: &TestOutput) -> Vec { + vec![OutputOwned::SharedControlFullyOwned(generate_txout(output))] + } + + /// Generate a single P2WSH output that is the funding output + fn generate_funding_output(value: u64) -> Vec { + generate_output(&TestOutput::P2WSH(value)) + } + + /// Generate a single P2WSH output with shared contribution that is the funding output + fn generate_shared_funding_output_one(value: u64, local_value: u64) -> OutputOwned { + OutputOwned::Shared(SharedOwnedOutput { + tx_out: generate_txout(&TestOutput::P2WSH(value)), + local_owned: local_value, + }) + } + + /// Generate a single P2WSH output with shared contribution that is the funding output + fn generate_shared_funding_output(value: u64, local_value: u64) -> Vec { + vec![generate_shared_funding_output_one(value, local_value)] } fn generate_fixed_number_of_inputs(count: u16) -> Vec<(TxIn, TransactionU16LenLimited)> { @@ -1118,7 +1704,7 @@ mod tests { // Use unique locktime for each tx so outpoints are different across transactions let tx = generate_tx_with_locktime( - &vec![1_000_000; tx_output_count as usize], + &vec![TestOutput::P2WPKH(1_000_000); tx_output_count as usize], (1337 + remaining).into(), ); let txid = tx.txid(); @@ -1144,123 +1730,214 @@ mod tests { inputs } - fn generate_fixed_number_of_outputs(count: u16) -> Vec { + fn generate_fixed_number_of_outputs(count: u16) -> Vec { // Set a constant value for each TxOut - generate_outputs(&vec![1_000_000; count as usize]) + generate_outputs(&vec![TestOutput::P2WPKH(1_000_000); count as usize]) } - fn generate_non_witness_output(value: u64) -> TxOut { - TxOut { - value, - script_pubkey: Builder::new().push_opcode(opcodes::OP_TRUE).into_script().to_p2sh(), - } + fn generate_p2sh_script_pubkey() -> ScriptBuf { + Builder::new().push_opcode(opcodes::OP_TRUE).into_script().to_p2sh() + } + + fn generate_non_witness_output(value: u64) -> OutputOwned { + OutputOwned::Single(TxOut { + value: Amount::from_sat(value), + script_pubkey: generate_p2sh_script_pubkey(), + }) } #[test] fn test_interactive_tx_constructor() { - // No contributions. do_test_interactive_tx_constructor(TestSession { + description: "No contributions", inputs_a: vec![], outputs_a: vec![], inputs_b: vec![], outputs_b: vec![], - expect_error: Some((AbortReason::InsufficientFees, ErrorCulprit::NodeA)), + expect_error: Some((AbortReason::MissingFundingOutput, ErrorCulprit::NodeA)), + a_expected_remote_shared_output: None, + b_expected_remote_shared_output: None, }); - // Single contribution, no initiator inputs. do_test_interactive_tx_constructor(TestSession { + description: "Single contribution, no initiator inputs", inputs_a: vec![], - outputs_a: generate_outputs(&[1_000_000]), + outputs_a: generate_output(&TestOutput::P2WPKH(1_000_000)), inputs_b: vec![], outputs_b: vec![], expect_error: Some((AbortReason::OutputsValueExceedsInputsValue, ErrorCulprit::NodeA)), + a_expected_remote_shared_output: None, + b_expected_remote_shared_output: Some((generate_p2wpkh_script_pubkey(), 0)), }); - // Single contribution, no initiator outputs. do_test_interactive_tx_constructor(TestSession { - inputs_a: generate_inputs(&[1_000_000]), + description: "Single contribution, no initiator outputs", + inputs_a: generate_inputs(&[TestOutput::P2WPKH(1_000_000)]), outputs_a: vec![], inputs_b: vec![], outputs_b: vec![], + expect_error: Some((AbortReason::MissingFundingOutput, ErrorCulprit::NodeA)), + a_expected_remote_shared_output: None, + b_expected_remote_shared_output: None, + }); + do_test_interactive_tx_constructor(TestSession { + description: "Single contribution, no fees", + inputs_a: generate_inputs(&[TestOutput::P2WPKH(1_000_000)]), + outputs_a: generate_output(&TestOutput::P2WPKH(1_000_000)), + inputs_b: vec![], + outputs_b: vec![], + expect_error: Some((AbortReason::InsufficientFees, ErrorCulprit::NodeA)), + a_expected_remote_shared_output: None, + b_expected_remote_shared_output: Some((generate_p2wpkh_script_pubkey(), 0)), + }); + let p2wpkh_fee = fee_for_weight(TEST_FEERATE_SATS_PER_KW, P2WPKH_INPUT_WEIGHT_LOWER_BOUND); + let outputs_fee = fee_for_weight( + TEST_FEERATE_SATS_PER_KW, + get_output_weight(&generate_p2wpkh_script_pubkey()).to_wu(), + ); + let tx_common_fields_fee = + fee_for_weight(TEST_FEERATE_SATS_PER_KW, TX_COMMON_FIELDS_WEIGHT); + + let amount_adjusted_with_p2wpkh_fee = + 1_000_000 - p2wpkh_fee - outputs_fee - tx_common_fields_fee; + do_test_interactive_tx_constructor(TestSession { + description: "Single contribution, with P2WPKH input, insufficient fees", + inputs_a: generate_inputs(&[TestOutput::P2WPKH(1_000_000)]), + outputs_a: generate_output(&TestOutput::P2WPKH( + amount_adjusted_with_p2wpkh_fee + 1, /* makes fees insuffcient for initiator */ + )), + inputs_b: vec![], + outputs_b: vec![], + expect_error: Some((AbortReason::InsufficientFees, ErrorCulprit::NodeA)), + a_expected_remote_shared_output: None, + b_expected_remote_shared_output: Some((generate_p2wpkh_script_pubkey(), 0)), + }); + do_test_interactive_tx_constructor(TestSession { + description: "Single contribution with P2WPKH input, sufficient fees", + inputs_a: generate_inputs(&[TestOutput::P2WPKH(1_000_000)]), + outputs_a: generate_output(&TestOutput::P2WPKH(amount_adjusted_with_p2wpkh_fee)), + inputs_b: vec![], + outputs_b: vec![], expect_error: None, + a_expected_remote_shared_output: None, + b_expected_remote_shared_output: Some((generate_p2wpkh_script_pubkey(), 0)), }); - // Single contribution, insufficient fees. + let p2wsh_fee = fee_for_weight(TEST_FEERATE_SATS_PER_KW, P2WSH_INPUT_WEIGHT_LOWER_BOUND); + let amount_adjusted_with_p2wsh_fee = + 1_000_000 - p2wsh_fee - outputs_fee - tx_common_fields_fee; do_test_interactive_tx_constructor(TestSession { - inputs_a: generate_inputs(&[1_000_000]), - outputs_a: generate_outputs(&[1_000_000]), + description: "Single contribution, with P2WSH input, insufficient fees", + inputs_a: generate_inputs(&[TestOutput::P2WSH(1_000_000)]), + outputs_a: generate_output(&TestOutput::P2WPKH( + amount_adjusted_with_p2wsh_fee + 1, /* makes fees insuffcient for initiator */ + )), inputs_b: vec![], outputs_b: vec![], expect_error: Some((AbortReason::InsufficientFees, ErrorCulprit::NodeA)), + a_expected_remote_shared_output: None, + b_expected_remote_shared_output: Some((generate_p2wpkh_script_pubkey(), 0)), }); - // Initiator contributes sufficient fees, but non-initiator does not. do_test_interactive_tx_constructor(TestSession { - inputs_a: generate_inputs(&[1_000_000]), + description: "Single contribution with P2WSH input, sufficient fees", + inputs_a: generate_inputs(&[TestOutput::P2WSH(1_000_000)]), + outputs_a: generate_output(&TestOutput::P2WPKH(amount_adjusted_with_p2wsh_fee)), + inputs_b: vec![], + outputs_b: vec![], + expect_error: None, + a_expected_remote_shared_output: None, + b_expected_remote_shared_output: Some((generate_p2wpkh_script_pubkey(), 0)), + }); + let p2tr_fee = fee_for_weight(TEST_FEERATE_SATS_PER_KW, P2TR_INPUT_WEIGHT_LOWER_BOUND); + let amount_adjusted_with_p2tr_fee = + 1_000_000 - p2tr_fee - outputs_fee - tx_common_fields_fee; + do_test_interactive_tx_constructor(TestSession { + description: "Single contribution, with P2TR input, insufficient fees", + inputs_a: generate_inputs(&[TestOutput::P2TR(1_000_000)]), + outputs_a: generate_output(&TestOutput::P2WPKH( + amount_adjusted_with_p2tr_fee + 1, /* makes fees insuffcient for initiator */ + )), + inputs_b: vec![], + outputs_b: vec![], + expect_error: Some((AbortReason::InsufficientFees, ErrorCulprit::NodeA)), + a_expected_remote_shared_output: None, + b_expected_remote_shared_output: Some((generate_p2wpkh_script_pubkey(), 0)), + }); + do_test_interactive_tx_constructor(TestSession { + description: "Single contribution with P2TR input, sufficient fees", + inputs_a: generate_inputs(&[TestOutput::P2TR(1_000_000)]), + outputs_a: generate_output(&TestOutput::P2WPKH(amount_adjusted_with_p2tr_fee)), + inputs_b: vec![], + outputs_b: vec![], + expect_error: None, + a_expected_remote_shared_output: None, + b_expected_remote_shared_output: Some((generate_p2wpkh_script_pubkey(), 0)), + }); + do_test_interactive_tx_constructor(TestSession { + description: "Initiator contributes sufficient fees, but non-initiator does not", + inputs_a: generate_inputs(&[TestOutput::P2WPKH(1_000_000)]), outputs_a: vec![], - inputs_b: generate_inputs(&[100_000]), - outputs_b: generate_outputs(&[100_000]), + inputs_b: generate_inputs(&[TestOutput::P2WPKH(100_000)]), + outputs_b: generate_output(&TestOutput::P2WPKH(100_000)), expect_error: Some((AbortReason::InsufficientFees, ErrorCulprit::NodeB)), + a_expected_remote_shared_output: Some((generate_p2wpkh_script_pubkey(), 0)), + b_expected_remote_shared_output: None, }); - // Multi-input-output contributions from both sides. do_test_interactive_tx_constructor(TestSession { - inputs_a: generate_inputs(&[1_000_000, 1_000_000]), - outputs_a: generate_outputs(&[1_000_000, 200_000]), - inputs_b: generate_inputs(&[1_000_000, 500_000]), - outputs_b: generate_outputs(&[1_000_000, 400_000]), + description: "Multi-input-output contributions from both sides", + inputs_a: generate_inputs(&[TestOutput::P2WPKH(1_000_000); 2]), + outputs_a: vec![ + generate_shared_funding_output_one(1_000_000, 200_000), + generate_output_nonfunding_one(&TestOutput::P2WPKH(200_000)), + ], + inputs_b: generate_inputs(&[ + TestOutput::P2WPKH(1_000_000), + TestOutput::P2WPKH(500_000), + ]), + outputs_b: vec![generate_output_nonfunding_one(&TestOutput::P2WPKH(400_000))], expect_error: None, + a_expected_remote_shared_output: None, + b_expected_remote_shared_output: Some((generate_funding_script_pubkey(), 800_000)), }); - // Prevout from initiator is not a witness program - let non_segwit_output_tx = { - let mut tx = generate_tx(&[1_000_000]); - tx.output.push(TxOut { - script_pubkey: Builder::new() - .push_opcode(opcodes::all::OP_RETURN) - .into_script() - .to_p2sh(), - ..Default::default() - }); - - TransactionU16LenLimited::new(tx).unwrap() - }; - let non_segwit_input = TxIn { - previous_output: OutPoint { - txid: non_segwit_output_tx.as_transaction().txid(), - vout: 1, - }, - sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, - ..Default::default() - }; do_test_interactive_tx_constructor(TestSession { - inputs_a: vec![(non_segwit_input, non_segwit_output_tx)], + description: "Prevout from initiator is not a witness program", + inputs_a: generate_inputs(&[TestOutput::P2PKH(1_000_000)]), outputs_a: vec![], inputs_b: vec![], outputs_b: vec![], expect_error: Some((AbortReason::PrevTxOutInvalid, ErrorCulprit::NodeA)), + a_expected_remote_shared_output: Some((generate_funding_script_pubkey(), 0)), + b_expected_remote_shared_output: Some((generate_funding_script_pubkey(), 0)), }); - // Invalid input sequence from initiator. - let tx = TransactionU16LenLimited::new(generate_tx(&[1_000_000])).unwrap(); + let tx = + TransactionU16LenLimited::new(generate_tx(&[TestOutput::P2WPKH(1_000_000)])).unwrap(); let invalid_sequence_input = TxIn { previous_output: OutPoint { txid: tx.as_transaction().txid(), vout: 0 }, ..Default::default() }; do_test_interactive_tx_constructor(TestSession { + description: "Invalid input sequence from initiator", inputs_a: vec![(invalid_sequence_input, tx.clone())], - outputs_a: generate_outputs(&[1_000_000]), + outputs_a: generate_output(&TestOutput::P2WPKH(1_000_000)), inputs_b: vec![], outputs_b: vec![], expect_error: Some((AbortReason::IncorrectInputSequenceValue, ErrorCulprit::NodeA)), + a_expected_remote_shared_output: None, + b_expected_remote_shared_output: Some((generate_p2wpkh_script_pubkey(), 0)), }); - // Duplicate prevout from initiator. let duplicate_input = TxIn { previous_output: OutPoint { txid: tx.as_transaction().txid(), vout: 0 }, sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, ..Default::default() }; do_test_interactive_tx_constructor(TestSession { + description: "Duplicate prevout from initiator", inputs_a: vec![(duplicate_input.clone(), tx.clone()), (duplicate_input, tx.clone())], - outputs_a: generate_outputs(&[1_000_000]), + outputs_a: generate_output(&TestOutput::P2WPKH(1_000_000)), inputs_b: vec![], outputs_b: vec![], - expect_error: Some((AbortReason::PrevTxOutInvalid, ErrorCulprit::NodeA)), + expect_error: Some((AbortReason::PrevTxOutInvalid, ErrorCulprit::NodeB)), + a_expected_remote_shared_output: None, + b_expected_remote_shared_output: Some((generate_p2wpkh_script_pubkey(), 0)), }); // Non-initiator uses same prevout as initiator. let duplicate_input = TxIn { @@ -1269,88 +1946,124 @@ mod tests { ..Default::default() }; do_test_interactive_tx_constructor(TestSession { + description: "Non-initiator uses same prevout as initiator", inputs_a: vec![(duplicate_input.clone(), tx.clone())], - outputs_a: generate_outputs(&[1_000_000]), + outputs_a: generate_shared_funding_output(1_000_000, 905_000), inputs_b: vec![(duplicate_input.clone(), tx.clone())], outputs_b: vec![], - expect_error: Some((AbortReason::PrevTxOutInvalid, ErrorCulprit::NodeB)), + expect_error: Some((AbortReason::PrevTxOutInvalid, ErrorCulprit::NodeA)), + a_expected_remote_shared_output: None, + b_expected_remote_shared_output: Some((generate_funding_script_pubkey(), 95_000)), + }); + let duplicate_input = TxIn { + previous_output: OutPoint { txid: tx.as_transaction().txid(), vout: 0 }, + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + ..Default::default() + }; + do_test_interactive_tx_constructor(TestSession { + description: "Non-initiator uses same prevout as initiator", + inputs_a: vec![(duplicate_input.clone(), tx.clone())], + outputs_a: generate_output(&TestOutput::P2WPKH(1_000_000)), + inputs_b: vec![(duplicate_input.clone(), tx.clone())], + outputs_b: vec![], + expect_error: Some((AbortReason::PrevTxOutInvalid, ErrorCulprit::NodeA)), + a_expected_remote_shared_output: None, + b_expected_remote_shared_output: Some((generate_p2wpkh_script_pubkey(), 0)), }); - // Initiator sends too many TxAddInputs do_test_interactive_tx_constructor(TestSession { + description: "Initiator sends too many TxAddInputs", inputs_a: generate_fixed_number_of_inputs(MAX_RECEIVED_TX_ADD_INPUT_COUNT + 1), outputs_a: vec![], inputs_b: vec![], outputs_b: vec![], expect_error: Some((AbortReason::ReceivedTooManyTxAddInputs, ErrorCulprit::NodeA)), + a_expected_remote_shared_output: Some((generate_funding_script_pubkey(), 0)), + b_expected_remote_shared_output: Some((generate_funding_script_pubkey(), 0)), }); - // Attempt to queue up two inputs with duplicate serial ids. We use a deliberately bad - // entropy source, `DuplicateEntropySource` to simulate this. do_test_interactive_tx_constructor_with_entropy_source( TestSession { + // We use a deliberately bad entropy source, `DuplicateEntropySource` to simulate this. + description: "Attempt to queue up two inputs with duplicate serial ids", inputs_a: generate_fixed_number_of_inputs(2), outputs_a: vec![], inputs_b: vec![], outputs_b: vec![], expect_error: Some((AbortReason::DuplicateSerialId, ErrorCulprit::NodeA)), + a_expected_remote_shared_output: Some((generate_funding_script_pubkey(), 0)), + b_expected_remote_shared_output: Some((generate_funding_script_pubkey(), 0)), }, &DuplicateEntropySource, ); - // Initiator sends too many TxAddOutputs. do_test_interactive_tx_constructor(TestSession { + description: "Initiator sends too many TxAddOutputs", inputs_a: vec![], outputs_a: generate_fixed_number_of_outputs(MAX_RECEIVED_TX_ADD_OUTPUT_COUNT + 1), inputs_b: vec![], outputs_b: vec![], expect_error: Some((AbortReason::ReceivedTooManyTxAddOutputs, ErrorCulprit::NodeA)), + a_expected_remote_shared_output: Some((generate_funding_script_pubkey(), 0)), + b_expected_remote_shared_output: Some((generate_funding_script_pubkey(), 0)), }); - // Initiator sends an output below dust value. do_test_interactive_tx_constructor(TestSession { + description: "Initiator sends an output below dust value", inputs_a: vec![], - outputs_a: generate_outputs(&[1]), + outputs_a: generate_funding_output( + generate_p2wsh_script_pubkey().dust_value().to_sat() - 1, + ), inputs_b: vec![], outputs_b: vec![], expect_error: Some((AbortReason::BelowDustLimit, ErrorCulprit::NodeA)), + a_expected_remote_shared_output: None, + b_expected_remote_shared_output: Some((generate_funding_script_pubkey(), 0)), }); - // Initiator sends an output above maximum sats allowed. do_test_interactive_tx_constructor(TestSession { + description: "Initiator sends an output above maximum sats allowed", inputs_a: vec![], - outputs_a: generate_outputs(&[TOTAL_BITCOIN_SUPPLY_SATOSHIS + 1]), + outputs_a: generate_output(&TestOutput::P2WPKH(TOTAL_BITCOIN_SUPPLY_SATOSHIS + 1)), inputs_b: vec![], outputs_b: vec![], expect_error: Some((AbortReason::ExceededMaximumSatsAllowed, ErrorCulprit::NodeA)), + a_expected_remote_shared_output: None, + b_expected_remote_shared_output: Some((generate_funding_script_pubkey(), 0)), }); - // Initiator sends an output without a witness program. do_test_interactive_tx_constructor(TestSession { + description: "Initiator sends an output without a witness program", inputs_a: vec![], outputs_a: vec![generate_non_witness_output(1_000_000)], inputs_b: vec![], outputs_b: vec![], expect_error: Some((AbortReason::InvalidOutputScript, ErrorCulprit::NodeA)), + a_expected_remote_shared_output: Some((generate_funding_script_pubkey(), 0)), + b_expected_remote_shared_output: Some((generate_funding_script_pubkey(), 0)), }); - // Attempt to queue up two outputs with duplicate serial ids. We use a deliberately bad - // entropy source, `DuplicateEntropySource` to simulate this. do_test_interactive_tx_constructor_with_entropy_source( TestSession { + // We use a deliberately bad entropy source, `DuplicateEntropySource` to simulate this. + description: "Attempt to queue up two outputs with duplicate serial ids", inputs_a: vec![], outputs_a: generate_fixed_number_of_outputs(2), inputs_b: vec![], outputs_b: vec![], expect_error: Some((AbortReason::DuplicateSerialId, ErrorCulprit::NodeA)), + a_expected_remote_shared_output: Some((generate_funding_script_pubkey(), 0)), + b_expected_remote_shared_output: Some((generate_funding_script_pubkey(), 0)), }, &DuplicateEntropySource, ); - // Peer contributed more output value than inputs do_test_interactive_tx_constructor(TestSession { - inputs_a: generate_inputs(&[100_000]), - outputs_a: generate_outputs(&[1_000_000]), + description: "Peer contributed more output value than inputs", + inputs_a: generate_inputs(&[TestOutput::P2WPKH(100_000)]), + outputs_a: generate_output(&TestOutput::P2WPKH(1_000_000)), inputs_b: vec![], outputs_b: vec![], expect_error: Some((AbortReason::OutputsValueExceedsInputsValue, ErrorCulprit::NodeA)), + a_expected_remote_shared_output: None, + b_expected_remote_shared_output: Some((generate_funding_script_pubkey(), 0)), }); - // Peer contributed more than allowed number of inputs. do_test_interactive_tx_constructor(TestSession { + description: "Peer contributed more than allowed number of inputs", inputs_a: generate_fixed_number_of_inputs(MAX_INPUTS_OUTPUTS_COUNT as u16 + 1), outputs_a: vec![], inputs_b: vec![], @@ -1359,10 +2072,12 @@ mod tests { AbortReason::ExceededNumberOfInputsOrOutputs, ErrorCulprit::Indeterminate, )), + a_expected_remote_shared_output: Some((generate_funding_script_pubkey(), 0)), + b_expected_remote_shared_output: Some((generate_funding_script_pubkey(), 0)), }); - // Peer contributed more than allowed number of outputs. do_test_interactive_tx_constructor(TestSession { - inputs_a: generate_inputs(&[TOTAL_BITCOIN_SUPPLY_SATOSHIS]), + description: "Peer contributed more than allowed number of outputs", + inputs_a: generate_inputs(&[TestOutput::P2WPKH(TOTAL_BITCOIN_SUPPLY_SATOSHIS)]), outputs_a: generate_fixed_number_of_outputs(MAX_INPUTS_OUTPUTS_COUNT as u16 + 1), inputs_b: vec![], outputs_b: vec![], @@ -1370,6 +2085,121 @@ mod tests { AbortReason::ExceededNumberOfInputsOrOutputs, ErrorCulprit::Indeterminate, )), + a_expected_remote_shared_output: Some((generate_funding_script_pubkey(), 0)), + b_expected_remote_shared_output: Some((generate_funding_script_pubkey(), 0)), + }); + + // Adding multiple outputs to the funding output pubkey is an error + do_test_interactive_tx_constructor(TestSession { + description: "Adding two outputs to the funding output pubkey", + inputs_a: generate_inputs(&[TestOutput::P2WPKH(1_000_000)]), + outputs_a: generate_funding_output(100_000), + inputs_b: generate_inputs(&[TestOutput::P2WPKH(1_001_000)]), + outputs_b: generate_funding_output(100_000), + expect_error: Some((AbortReason::DuplicateFundingOutput, ErrorCulprit::NodeA)), + a_expected_remote_shared_output: None, + b_expected_remote_shared_output: None, + }); + + // We add the funding output, but we contribute a little + do_test_interactive_tx_constructor(TestSession { + description: "Funding output by us, small contribution", + inputs_a: generate_inputs(&[TestOutput::P2WPKH(12_000)]), + outputs_a: generate_shared_funding_output(1_000_000, 10_000), + inputs_b: generate_inputs(&[TestOutput::P2WPKH(992_000)]), + outputs_b: vec![], + expect_error: None, + a_expected_remote_shared_output: None, + b_expected_remote_shared_output: Some((generate_funding_script_pubkey(), 990_000)), + }); + + // They add the funding output, and we contribute a little + do_test_interactive_tx_constructor(TestSession { + description: "Funding output by them, small contribution", + inputs_a: generate_inputs(&[TestOutput::P2WPKH(12_000)]), + outputs_a: vec![], + inputs_b: generate_inputs(&[TestOutput::P2WPKH(992_000)]), + outputs_b: generate_shared_funding_output(1_000_000, 990_000), + expect_error: None, + a_expected_remote_shared_output: Some((generate_funding_script_pubkey(), 10_000)), + b_expected_remote_shared_output: None, + }); + + // We add the funding output, and we contribute most + do_test_interactive_tx_constructor(TestSession { + description: "Funding output by us, large contribution", + inputs_a: generate_inputs(&[TestOutput::P2WPKH(992_000)]), + outputs_a: generate_shared_funding_output(1_000_000, 990_000), + inputs_b: generate_inputs(&[TestOutput::P2WPKH(12_000)]), + outputs_b: vec![], + expect_error: None, + a_expected_remote_shared_output: None, + b_expected_remote_shared_output: Some((generate_funding_script_pubkey(), 10_000)), + }); + + // They add the funding output, but we contribute most + do_test_interactive_tx_constructor(TestSession { + description: "Funding output by them, large contribution", + inputs_a: generate_inputs(&[TestOutput::P2WPKH(992_000)]), + outputs_a: vec![], + inputs_b: generate_inputs(&[TestOutput::P2WPKH(12_000)]), + outputs_b: generate_shared_funding_output(1_000_000, 10_000), + expect_error: None, + a_expected_remote_shared_output: Some((generate_funding_script_pubkey(), 990_000)), + b_expected_remote_shared_output: None, + }); + + // During a splice-out, with peer providing more output value than input value + // but still pays enough fees due to their to_remote_value_satoshis portion in + // the shared input. + do_test_interactive_tx_constructor(TestSession { + description: "Splice out with sufficient initiator balance", + inputs_a: generate_inputs(&[TestOutput::P2WPKH(100_000), TestOutput::P2WPKH(50_000)]), + outputs_a: generate_funding_output(120_000), + inputs_b: generate_inputs(&[TestOutput::P2WPKH(50_000)]), + outputs_b: vec![], + expect_error: None, + a_expected_remote_shared_output: None, + b_expected_remote_shared_output: Some((generate_funding_script_pubkey(), 0)), + }); + + // During a splice-out, with peer providing more output value than input value + // and the to_remote_value_satoshis portion in + // the shared input cannot cover fees + do_test_interactive_tx_constructor(TestSession { + description: "Splice out with insufficient initiator balance", + inputs_a: generate_inputs(&[TestOutput::P2WPKH(100_000), TestOutput::P2WPKH(15_000)]), + outputs_a: generate_funding_output(120_000), + inputs_b: generate_inputs(&[TestOutput::P2WPKH(85_000)]), + outputs_b: vec![], + expect_error: Some((AbortReason::OutputsValueExceedsInputsValue, ErrorCulprit::NodeA)), + a_expected_remote_shared_output: None, + b_expected_remote_shared_output: Some((generate_funding_script_pubkey(), 0)), + }); + + // The actual funding output value is lower than the intended local contribution by the same node + do_test_interactive_tx_constructor(TestSession { + description: "Splice in, invalid intended local contribution", + inputs_a: generate_inputs(&[TestOutput::P2WPKH(100_000), TestOutput::P2WPKH(15_000)]), + outputs_a: generate_shared_funding_output(100_000, 120_000), // local value is higher than the output value + inputs_b: generate_inputs(&[TestOutput::P2WPKH(85_000)]), + outputs_b: vec![], + expect_error: Some((AbortReason::InvalidLowFundingOutputValue, ErrorCulprit::NodeA)), + a_expected_remote_shared_output: None, + b_expected_remote_shared_output: Some((generate_funding_script_pubkey(), 20_000)), + }); + + // The actual funding output value is lower than the intended local contribution of the other node + do_test_interactive_tx_constructor(TestSession { + description: "Splice in, invalid intended local contribution", + inputs_a: generate_inputs(&[TestOutput::P2WPKH(100_000), TestOutput::P2WPKH(15_000)]), + outputs_a: vec![], + inputs_b: generate_inputs(&[TestOutput::P2WPKH(85_000)]), + outputs_b: generate_funding_output(100_000), + // The error is caused by NodeA, it occurs when nodeA prepares the message to be sent to NodeB, that's why here it shows up as NodeB + expect_error: Some((AbortReason::InvalidLowFundingOutputValue, ErrorCulprit::NodeB)), + a_expected_remote_shared_output: Some((generate_funding_script_pubkey(), 120_000)), // this is higher than the actual output value + b_expected_remote_shared_output: None, }); }