diff --git a/lightning/src/ln/interactivetxs.rs b/lightning/src/ln/interactivetxs.rs index 5f01bcd163b..06af88e4e5a 100644 --- a/lightning/src/ln/interactivetxs.rs +++ b/lightning/src/ln/interactivetxs.rs @@ -9,15 +9,12 @@ use crate::io_extras::sink; use crate::prelude::*; -use core::ops::Deref; +use bitcoin::absolute::LockTime as AbsoluteLockTime; 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, ScriptBuf, Sequence, Transaction, TxIn, - TxOut, Weight, -}; +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}; @@ -27,6 +24,9 @@ use crate::ln::{msgs, ChannelId}; use crate::sign::{EntropySource, P2TR_KEY_PATH_WITNESS_WEIGHT, P2WPKH_WITNESS_WEIGHT}; use crate::util::ser::TransactionU16LenLimited; +use core::cmp; +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; @@ -96,19 +96,7 @@ pub(crate) enum AbortReason { InsufficientFees, OutputsValueExceedsInputsValue, InvalidTx, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct InteractiveTxInput { - serial_id: SerialId, - input: TxIn, - prev_output: TxOut, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct InteractiveTxOutput { - serial_id: SerialId, - tx_out: TxOut, + DuplicateFundingOutput, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -132,18 +120,12 @@ impl ConstructedTransaction { let local_inputs_value_satoshis = context .inputs .iter() - .filter(|(serial_id, _)| { - !is_serial_id_valid_for_counterparty(context.holder_is_initiator, serial_id) - }) - .fold(0u64, |value, (_, input)| value.saturating_add(input.prev_output.value)); + .fold(0u64, |value, (_, input)| value.saturating_add(input.local_value())); let local_outputs_value_satoshis = context .outputs .iter() - .filter(|(serial_id, _)| { - !is_serial_id_valid_for_counterparty(context.holder_is_initiator, serial_id) - }) - .fold(0u64, |value, (_, output)| value.saturating_add(output.tx_out.value)); + .fold(0u64, |value, (_, output)| value.saturating_add(output.local_value())); Self { holder_is_initiator: context.holder_is_initiator, @@ -162,18 +144,12 @@ impl ConstructedTransaction { } pub fn weight(&self) -> Weight { - let inputs_weight = self.inputs.iter().fold( - Weight::from_wu(0), - |weight, InteractiveTxInput { prev_output, .. }| { - weight.checked_add(estimate_input_weight(prev_output)).unwrap_or(Weight::MAX) - }, - ); - let outputs_weight = self.outputs.iter().fold( - Weight::from_wu(0), - |weight, InteractiveTxOutput { tx_out, .. }| { - weight.checked_add(get_output_weight(&tx_out.script_pubkey)).unwrap_or(Weight::MAX) - }, - ); + 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)) @@ -184,13 +160,11 @@ impl ConstructedTransaction { // Inputs and outputs must be sorted by serial_id let ConstructedTransaction { mut inputs, mut outputs, .. } = self; - inputs.sort_unstable_by_key(|InteractiveTxInput { serial_id, .. }| *serial_id); - outputs.sort_unstable_by_key(|InteractiveTxOutput { serial_id, .. }| *serial_id); + 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(|InteractiveTxInput { input, .. }| input).collect(); - let output: Vec = - outputs.into_iter().map(|InteractiveTxOutput { tx_out, .. }| tx_out).collect(); + let input: Vec = inputs.into_iter().map(|input| input.txin().clone()).collect(); + let output: Vec = outputs.into_iter().map(|output| output.txout().clone()).collect(); Transaction { version: 2, lock_time: self.lock_time, input, output } } @@ -202,9 +176,26 @@ struct NegotiationContext { received_tx_add_input_count: u16, received_tx_add_output_count: u16, inputs: HashMap, + /// The output script intended to be the new funding output script. + /// This field is used to determine which output is the funding output. + /// When an output is added with the same script pubkey, it will be treated as the shared output. + intended_new_funding_output_script: ScriptBuf, + /// 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 this value. + /// (In the rare case of actual output value differing from intended, the intended local + /// contribution may be clipped.) + intended_local_contribution_satoshis: 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, + /// The outputs (excluding the current funding output) outputs: HashMap, + /// The locktime of the funding transaction. tx_locktime: AbsoluteLockTime, + /// The fee rate used for the transaction feerate_sat_per_kw: u32, } @@ -233,26 +224,61 @@ fn is_serial_id_valid_for_counterparty(holder_is_initiator: bool, serial_id: &Se } impl NegotiationContext { + fn new( + holder_is_initiator: bool, intended_new_funding_output_script: ScriptBuf, + intended_local_contribution_satoshis: 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(), + intended_new_funding_output_script, + intended_local_contribution_satoshis, + 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, serial_id: SerialId, txout: TxOut, + ) -> Result { + if self.actual_new_funding_output.is_some() { + return Err(AbortReason::DuplicateFundingOutput); + } + + let value = txout.value; + // clamp local contribution if needed + let local_value = cmp::min(value, self.intended_local_contribution_satoshis); + let shared_output = SharedOutput { + serial_id, + txout, + local_value, + remote_value: value.saturating_sub(local_value), + }; + + self.actual_new_funding_output = Some(shared_output.clone()); + Ok(shared_output) + } + fn is_serial_id_valid_for_counterparty(&self, serial_id: &SerialId) -> bool { 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() - .filter(|(serial_id, _)| self.is_serial_id_valid_for_counterparty(serial_id)) - .fold(0u64, |acc, (_, InteractiveTxInput { prev_output, .. })| { - acc.saturating_add(prev_output.value) - }) + self.inputs.iter().fold(0u64, |acc, (_, input)| acc.saturating_add(input.remote_value())) } fn remote_outputs_value(&self) -> u64 { - self.outputs - .iter() - .filter(|(serial_id, _)| self.is_serial_id_valid_for_counterparty(serial_id)) - .fold(0u64, |acc, (_, InteractiveTxOutput { tx_out, .. })| { - acc.saturating_add(tx_out.value) - }) + self.outputs.iter().fold(0u64, |acc, (_, output)| acc.saturating_add(output.remote_value())) } fn remote_inputs_weight(&self) -> Weight { @@ -260,8 +286,8 @@ impl NegotiationContext { self.inputs .iter() .filter(|(serial_id, _)| self.is_serial_id_valid_for_counterparty(serial_id)) - .fold(0u64, |weight, (_, InteractiveTxInput { prev_output, .. })| { - weight.saturating_add(estimate_input_weight(prev_output).to_wu()) + .fold(0u64, |weight, (_, input)| { + weight.saturating_add(estimate_input_weight(input.prev_output()).to_wu()) }), ) } @@ -271,8 +297,8 @@ impl NegotiationContext { self.outputs .iter() .filter(|(serial_id, _)| self.is_serial_id_valid_for_counterparty(serial_id)) - .fold(0u64, |weight, (_, InteractiveTxOutput { tx_out, .. })| { - weight.saturating_add(get_output_weight(&tx_out.script_pubkey).to_wu()) + .fold(0u64, |weight, (_, output)| { + weight.saturating_add(get_output_weight(&output.script_pubkey()).to_wu()) }), ) } @@ -344,7 +370,7 @@ impl NegotiationContext { }, hash_map::Entry::Vacant(entry) => { let prev_outpoint = OutPoint { txid, vout: msg.prevtx_out }; - entry.insert(InteractiveTxInput { + entry.insert(InteractiveTxInput::Remote(LocalOrRemoteInput { serial_id: msg.serial_id, input: TxIn { previous_output: prev_outpoint, @@ -352,7 +378,7 @@ impl NegotiationContext { ..Default::default() }, prev_output: prev_out, - }); + })); self.prevtx_outpoints.insert(prev_outpoint); Ok(()) }, @@ -401,7 +427,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.tx_out.value); + outputs_value = outputs_value.saturating_add(output.1.value()); } if outputs_value.saturating_add(msg.sats) > TOTAL_BITCOIN_SUPPLY_SATOSHIS { // The receiving node: @@ -430,6 +456,14 @@ impl NegotiationContext { return Err(AbortReason::InvalidOutputScript); } + let txout = TxOut { value: msg.sats, script_pubkey: msg.script.clone() }; + let output = if msg.script == self.intended_new_funding_output_script { + // this is a shared funding output + let shared_output = self.set_actual_new_funding_output(msg.serial_id, txout)?; + InteractiveTxOutput::Shared(shared_output) + } else { + InteractiveTxOutput::Remote(LocalOrRemoteOutput { serial_id: msg.serial_id, txout }) + }; match self.outputs.entry(msg.serial_id) { hash_map::Entry::Occupied(_) => { // The receiving node: @@ -438,10 +472,7 @@ impl NegotiationContext { Err(AbortReason::DuplicateSerialId) }, hash_map::Entry::Vacant(entry) => { - entry.insert(InteractiveTxOutput { - serial_id: msg.serial_id, - tx_out: TxOut { value: msg.sats, script_pubkey: msg.script.clone() }, - }); + entry.insert(output); Ok(()) }, } @@ -464,32 +495,41 @@ impl NegotiationContext { 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() }; - let prev_output = - tx.output.get(msg.prevtx_out as usize).ok_or(AbortReason::PrevTxOutInvalid)?.clone(); - if !self.prevtx_outpoints.insert(input.previous_output) { + if !self.prevtx_outpoints.insert(txin.previous_output.clone()) { // We have added an input that already exists return Err(AbortReason::PrevTxOutInvalid); } - self.inputs.insert( - msg.serial_id, - InteractiveTxInput { serial_id: msg.serial_id, input, prev_output }, - ); + let vout = txin.previous_output.vout as usize; + let input = InteractiveTxInput::Local(LocalOrRemoteInput { + serial_id: msg.serial_id, + input: txin, + prev_output: msg + .prevtx + .as_transaction() + .output + .get(vout) + .ok_or(AbortReason::PrevTxOutInvalid)? + .clone(), + }); + self.inputs.insert(msg.serial_id, input); Ok(()) } fn sent_tx_add_output(&mut self, msg: &msgs::TxAddOutput) -> Result<(), AbortReason> { - self.outputs.insert( - msg.serial_id, - InteractiveTxOutput { - serial_id: msg.serial_id, - tx_out: TxOut { value: msg.sats, script_pubkey: msg.script.clone() }, - }, - ); + let txout = TxOut { value: msg.sats, script_pubkey: msg.script.clone() }; + let output = if msg.script == self.intended_new_funding_output_script { + // this is a shared funding output + let shared_output = self.set_actual_new_funding_output(msg.serial_id, txout)?; + InteractiveTxOutput::Shared(shared_output) + } else { + InteractiveTxOutput::Local(LocalOrRemoteOutput { serial_id: msg.serial_id, txout }) + }; + self.outputs.insert(msg.serial_id, output); Ok(()) } @@ -753,17 +793,17 @@ macro_rules! define_state_machine_transitions { } impl StateMachine { - fn new(feerate_sat_per_kw: u32, is_initiator: bool, tx_locktime: AbsoluteLockTime) -> Self { - let context = NegotiationContext { + fn new( + feerate_sat_per_kw: u32, is_initiator: bool, tx_locktime: AbsoluteLockTime, + intended_new_funding_output_script: ScriptBuf, intended_local_contribution_satoshis: u64, + ) -> Self { + let context = NegotiationContext::new( + is_initiator, + intended_new_funding_output_script, + intended_local_contribution_satoshis, 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, - }; + ); if is_initiator { Self::ReceivedChangeMsg(ReceivedChangeMsg(context)) } else { @@ -822,6 +862,126 @@ impl StateMachine { ]); } +/// 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 +} + +/// Represents an output -- local or remote (both have the same fields) +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LocalOrRemoteOutput { + serial_id: SerialId, + txout: TxOut, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SharedOutput { + serial_id: SerialId, + txout: TxOut, + remote_value: u64, + local_value: u64, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum InteractiveTxOutput { + Local(LocalOrRemoteOutput), + Remote(LocalOrRemoteOutput), + Shared(SharedOutput), +} + +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 + } + + pub fn local_value(&self) -> u64 { + match self { + InteractiveTxInput::Local(input) => input.prev_output.value, + InteractiveTxInput::Remote(_input) => 0, + } + } + + pub fn remote_value(&self) -> u64 { + match self { + InteractiveTxInput::Local(_input) => 0, + InteractiveTxInput::Remote(input) => input.prev_output.value, + } + } +} + +impl InteractiveTxOutput { + pub fn serial_id(&self) -> SerialId { + match self { + InteractiveTxOutput::Local(output) => output.serial_id, + InteractiveTxOutput::Remote(output) => output.serial_id, + InteractiveTxOutput::Shared(output) => output.serial_id, + } + } + + pub fn txout(&self) -> &TxOut { + match self { + InteractiveTxOutput::Local(output) => &output.txout, + InteractiveTxOutput::Remote(output) => &output.txout, + InteractiveTxOutput::Shared(output) => &output.txout, + } + } + + pub fn value(&self) -> u64 { + self.txout().value + } + + pub fn script_pubkey(&self) -> &ScriptBuf { + &self.txout().script_pubkey + } + + pub fn local_value(&self) -> u64 { + match self { + InteractiveTxOutput::Local(output) => output.txout.value, + InteractiveTxOutput::Remote(_output) => 0, + InteractiveTxOutput::Shared(output) => output.local_value, + } + } + + pub fn remote_value(&self) -> u64 { + match self { + InteractiveTxOutput::Local(_output) => 0, + InteractiveTxOutput::Remote(output) => output.txout.value, + InteractiveTxOutput::Shared(output) => output.remote_value, + } + } +} + pub(crate) struct InteractiveTxConstructor { state_machine: StateMachine, channel_id: ChannelId, @@ -876,13 +1036,19 @@ 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, + outputs_to_contribute: Vec, intended_new_funding_output_script: ScriptBuf, + intended_local_contribution_satoshis: u64, ) -> (Self, Option) where ES::Target: EntropySource, { - let state_machine = - StateMachine::new(feerate_sat_per_kw, is_initiator, funding_tx_locktime); + let state_machine = StateMachine::new( + feerate_sat_per_kw, + is_initiator, + funding_tx_locktime, + intended_new_funding_output_script, + intended_local_contribution_satoshis, + ); let mut inputs_to_contribute: Vec<(SerialId, TxIn, TransactionU16LenLimited)> = inputs_to_contribute .into_iter() @@ -1027,15 +1193,15 @@ mod tests { use crate::sign::EntropySource; use crate::util::atomic_counter::AtomicCounter; use crate::util::ser::TransactionU16LenLimited; + use bitcoin::absolute::LockTime as AbsoluteLockTime; use bitcoin::blockdata::opcodes; use bitcoin::blockdata::script::Builder; use bitcoin::hashes::Hash; use bitcoin::key::UntweakedPublicKey; use bitcoin::secp256k1::{KeyPair, Secp256k1}; use bitcoin::{ - absolute::LockTime as AbsoluteLockTime, OutPoint, Sequence, Transaction, TxIn, TxOut, + OutPoint, PubkeyHash, ScriptBuf, Sequence, Transaction, TxIn, TxOut, WPubkeyHash, }; - use bitcoin::{PubkeyHash, ScriptBuf, WPubkeyHash, WScriptHash}; use core::ops::Deref; use super::{ @@ -1091,6 +1257,12 @@ mod tests { outputs_a: Vec, inputs_b: Vec<(TxIn, TransactionU16LenLimited)>, outputs_b: Vec, + /// The intended funding output, determines which output is to be considered as shared + intended_new_funding_output: TxOut, + /// B node's part from the shared output + b_funding_satoshis: u64, + /// A node's part from the shared output + a_funding_satoshis: u64, expect_error: Option<(AbortReason, ErrorCulprit)>, } @@ -1115,6 +1287,14 @@ mod tests { let channel_id = ChannelId(entropy_source.get_secure_random_bytes()); let tx_locktime = AbsoluteLockTime::from_height(1337).unwrap(); + // funding output sanity check + assert!(session.a_funding_satoshis <= session.intended_new_funding_output.value); + assert!(session.b_funding_satoshis <= session.intended_new_funding_output.value); + assert_eq!( + session.a_funding_satoshis + session.b_funding_satoshis, + session.intended_new_funding_output.value + ); + let (mut constructor_a, first_message_a) = InteractiveTxConstructor::new( entropy_source, channel_id, @@ -1123,6 +1303,8 @@ mod tests { tx_locktime, session.inputs_a, session.outputs_a, + session.intended_new_funding_output.script_pubkey.clone(), + session.a_funding_satoshis, ); let (mut constructor_b, first_message_b) = InteractiveTxConstructor::new( entropy_source, @@ -1132,6 +1314,8 @@ mod tests { tx_locktime, session.inputs_b, session.outputs_b, + session.intended_new_funding_output.script_pubkey, + session.b_funding_satoshis, ); let handle_message_send = @@ -1182,7 +1366,7 @@ mod tests { "Test: {}", session.description ); - assert!(message_send_b.is_none()); + assert!(message_send_b.is_none(), "Test: {}", session.description); return; }, } @@ -1206,7 +1390,7 @@ mod tests { "Test: {}", session.description ); - assert!(message_send_a.is_none()); + assert!(message_send_a.is_none(), "Test: {}", session.description); return; }, } @@ -1215,12 +1399,17 @@ mod tests { assert!(message_send_a.is_none()); assert!(message_send_b.is_none()); assert_eq!(final_tx_a.unwrap().into_unsigned_tx(), final_tx_b.unwrap().into_unsigned_tx()); - assert!(session.expect_error.is_none(), "Test: {}", session.description); + assert!( + session.expect_error.is_none(), + "Missing expected error, Test: {}", + 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. @@ -1237,9 +1426,7 @@ mod tests { TestOutput::P2WPKH(value) => { (*value, ScriptBuf::new_v0_p2wpkh(&WPubkeyHash::from_slice(&[1; 20]).unwrap())) }, - TestOutput::P2WSH(value) => { - (*value, ScriptBuf::new_v0_p2wsh(&WScriptHash::from_slice(&[2; 32]).unwrap())) - }, + TestOutput::P2WSH(value) => (*value, generate_funding_script_pubkey()), TestOutput::P2TR(value) => ( *value, ScriptBuf::new_v1_p2tr( @@ -1294,10 +1481,18 @@ mod tests { ScriptBuf::new_v0_p2wpkh(&WPubkeyHash::from_slice(&[1; 20]).unwrap()) } + fn generate_funding_script_pubkey() -> ScriptBuf { + Builder::new().push_int(33).into_script().to_v0_p2wsh() + } + fn generate_outputs(outputs: &[TestOutput]) -> Vec { outputs.iter().map(generate_txout).collect() } + fn generate_funding_output(value: u64) -> TxOut { + TxOut { value, script_pubkey: generate_funding_script_pubkey() } + } + fn generate_fixed_number_of_inputs(count: u16) -> Vec<(TxIn, TransactionU16LenLimited)> { // Generate transactions with a total `count` number of outputs such that no transaction has a // serialized length greater than u16::MAX. @@ -1359,6 +1554,9 @@ mod tests { inputs_b: vec![], outputs_b: vec![], expect_error: Some((AbortReason::InsufficientFees, ErrorCulprit::NodeA)), + intended_new_funding_output: generate_funding_output(0), + b_funding_satoshis: 0, + a_funding_satoshis: 0, }); do_test_interactive_tx_constructor(TestSession { description: "Single contribution, no initiator inputs", @@ -1367,6 +1565,9 @@ mod tests { inputs_b: vec![], outputs_b: vec![], expect_error: Some((AbortReason::OutputsValueExceedsInputsValue, ErrorCulprit::NodeA)), + intended_new_funding_output: generate_funding_output(1_000_000), + b_funding_satoshis: 0, + a_funding_satoshis: 1_000_000, }); do_test_interactive_tx_constructor(TestSession { description: "Single contribution, no initiator outputs", @@ -1375,6 +1576,9 @@ mod tests { inputs_b: vec![], outputs_b: vec![], expect_error: None, + intended_new_funding_output: generate_funding_output(0), + b_funding_satoshis: 0, + a_funding_satoshis: 0, }); do_test_interactive_tx_constructor(TestSession { description: "Single contribution, no fees", @@ -1383,6 +1587,9 @@ mod tests { inputs_b: vec![], outputs_b: vec![], expect_error: Some((AbortReason::InsufficientFees, ErrorCulprit::NodeA)), + intended_new_funding_output: generate_funding_output(1_000_000), + b_funding_satoshis: 0, + a_funding_satoshis: 1_000_000, }); let p2wpkh_fee = fee_for_weight(TEST_FEERATE_SATS_PER_KW, P2WPKH_INPUT_WEIGHT_LOWER_BOUND); let outputs_fee = fee_for_weight( @@ -1391,67 +1598,90 @@ mod tests { ); 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_outputs(&[TestOutput::P2WPKH( - 1_000_000 - p2wpkh_fee - outputs_fee - tx_common_fields_fee + 1, /* makes fees insuffcient for initiator */ + amount_adjusted_with_p2wpkh_fee + 1, /* makes fees insuffcient for initiator */ )]), inputs_b: vec![], outputs_b: vec![], expect_error: Some((AbortReason::InsufficientFees, ErrorCulprit::NodeA)), + intended_new_funding_output: generate_funding_output( + amount_adjusted_with_p2wpkh_fee + 1, + ), + b_funding_satoshis: 0, + a_funding_satoshis: amount_adjusted_with_p2wpkh_fee + 1, }); 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_outputs(&[TestOutput::P2WPKH( - 1_000_000 - p2wpkh_fee - outputs_fee - tx_common_fields_fee, - )]), + outputs_a: generate_outputs(&[TestOutput::P2WPKH(amount_adjusted_with_p2wpkh_fee)]), inputs_b: vec![], outputs_b: vec![], expect_error: None, + intended_new_funding_output: generate_funding_output(amount_adjusted_with_p2wpkh_fee), + b_funding_satoshis: 0, + a_funding_satoshis: amount_adjusted_with_p2wpkh_fee, }); 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 { description: "Single contribution, with P2WSH input, insufficient fees", inputs_a: generate_inputs(&[TestOutput::P2WSH(1_000_000)]), outputs_a: generate_outputs(&[TestOutput::P2WPKH( - 1_000_000 - p2wsh_fee - outputs_fee - tx_common_fields_fee + 1, /* makes fees insuffcient for initiator */ + amount_adjusted_with_p2wsh_fee + 1, /* makes fees insuffcient for initiator */ )]), inputs_b: vec![], outputs_b: vec![], expect_error: Some((AbortReason::InsufficientFees, ErrorCulprit::NodeA)), + intended_new_funding_output: generate_funding_output( + amount_adjusted_with_p2wsh_fee + 1, + ), + b_funding_satoshis: 0, + a_funding_satoshis: amount_adjusted_with_p2wsh_fee + 1, }); do_test_interactive_tx_constructor(TestSession { description: "Single contribution with P2WSH input, sufficient fees", inputs_a: generate_inputs(&[TestOutput::P2WSH(1_000_000)]), - outputs_a: generate_outputs(&[TestOutput::P2WPKH( - 1_000_000 - p2wsh_fee - outputs_fee - tx_common_fields_fee, - )]), + outputs_a: generate_outputs(&[TestOutput::P2WPKH(amount_adjusted_with_p2wsh_fee)]), inputs_b: vec![], outputs_b: vec![], expect_error: None, + intended_new_funding_output: generate_funding_output(amount_adjusted_with_p2wsh_fee), + b_funding_satoshis: 0, + a_funding_satoshis: amount_adjusted_with_p2wsh_fee, }); 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_outputs(&[TestOutput::P2WPKH( - 1_000_000 - p2tr_fee - outputs_fee - tx_common_fields_fee + 1, /* makes fees insuffcient for initiator */ + amount_adjusted_with_p2tr_fee + 1, /* makes fees insuffcient for initiator */ )]), inputs_b: vec![], outputs_b: vec![], expect_error: Some((AbortReason::InsufficientFees, ErrorCulprit::NodeA)), + intended_new_funding_output: generate_funding_output(amount_adjusted_with_p2tr_fee + 1), + b_funding_satoshis: 0, + a_funding_satoshis: amount_adjusted_with_p2tr_fee + 1, }); 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_outputs(&[TestOutput::P2WPKH( - 1_000_000 - p2tr_fee - outputs_fee - tx_common_fields_fee, - )]), + outputs_a: generate_outputs(&[TestOutput::P2WPKH(amount_adjusted_with_p2tr_fee)]), inputs_b: vec![], outputs_b: vec![], expect_error: None, + intended_new_funding_output: generate_funding_output(amount_adjusted_with_p2tr_fee), + b_funding_satoshis: 0, + a_funding_satoshis: amount_adjusted_with_p2tr_fee, }); do_test_interactive_tx_constructor(TestSession { description: "Initiator contributes sufficient fees, but non-initiator does not", @@ -1460,6 +1690,9 @@ mod tests { inputs_b: generate_inputs(&[TestOutput::P2WPKH(100_000)]), outputs_b: generate_outputs(&[TestOutput::P2WPKH(100_000)]), expect_error: Some((AbortReason::InsufficientFees, ErrorCulprit::NodeB)), + intended_new_funding_output: generate_funding_output(100_000), + b_funding_satoshis: 100_000, + a_funding_satoshis: 0, }); do_test_interactive_tx_constructor(TestSession { description: "Multi-input-output contributions from both sides", @@ -1477,6 +1710,9 @@ mod tests { TestOutput::P2WPKH(400_000), ]), expect_error: None, + intended_new_funding_output: generate_funding_output(1_000_000), + b_funding_satoshis: 800_000, + a_funding_satoshis: 200_000, }); do_test_interactive_tx_constructor(TestSession { @@ -1486,6 +1722,9 @@ mod tests { inputs_b: vec![], outputs_b: vec![], expect_error: Some((AbortReason::PrevTxOutInvalid, ErrorCulprit::NodeA)), + intended_new_funding_output: generate_funding_output(0), + b_funding_satoshis: 0, + a_funding_satoshis: 0, }); let tx = @@ -1501,6 +1740,9 @@ mod tests { inputs_b: vec![], outputs_b: vec![], expect_error: Some((AbortReason::IncorrectInputSequenceValue, ErrorCulprit::NodeA)), + intended_new_funding_output: generate_funding_output(1_000_000), + b_funding_satoshis: 0, + a_funding_satoshis: 1_000_000, }); let duplicate_input = TxIn { previous_output: OutPoint { txid: tx.as_transaction().txid(), vout: 0 }, @@ -1514,6 +1756,26 @@ mod tests { inputs_b: vec![], outputs_b: vec![], expect_error: Some((AbortReason::PrevTxOutInvalid, ErrorCulprit::NodeB)), + intended_new_funding_output: generate_funding_output(1_000_000), + b_funding_satoshis: 0, + a_funding_satoshis: 1_000_000, + }); + // Non-initiator uses same prevout as 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: "Non-initiator uses same prevout as initiator".into(), + inputs_a: vec![(duplicate_input.clone(), tx.clone())], + outputs_a: generate_outputs(&[TestOutput::P2WSH(1_000_000)]), + inputs_b: vec![(duplicate_input.clone(), tx.clone())], + outputs_b: vec![], + expect_error: Some((AbortReason::PrevTxOutInvalid, ErrorCulprit::NodeA)), + intended_new_funding_output: generate_funding_output(1_000_000), + b_funding_satoshis: 95_000, + a_funding_satoshis: 905_000, }); let duplicate_input = TxIn { previous_output: OutPoint { txid: tx.as_transaction().txid(), vout: 0 }, @@ -1527,6 +1789,9 @@ mod tests { inputs_b: vec![(duplicate_input.clone(), tx.clone())], outputs_b: vec![], expect_error: Some((AbortReason::PrevTxOutInvalid, ErrorCulprit::NodeA)), + intended_new_funding_output: generate_funding_output(1_000_000), + b_funding_satoshis: 0, + a_funding_satoshis: 1_000_000, }); do_test_interactive_tx_constructor(TestSession { description: "Initiator sends too many TxAddInputs", @@ -1535,6 +1800,9 @@ mod tests { inputs_b: vec![], outputs_b: vec![], expect_error: Some((AbortReason::ReceivedTooManyTxAddInputs, ErrorCulprit::NodeA)), + intended_new_funding_output: generate_funding_output(0), + b_funding_satoshis: 0, + a_funding_satoshis: 0, }); do_test_interactive_tx_constructor_with_entropy_source( TestSession { @@ -1545,6 +1813,9 @@ mod tests { inputs_b: vec![], outputs_b: vec![], expect_error: Some((AbortReason::DuplicateSerialId, ErrorCulprit::NodeA)), + intended_new_funding_output: generate_funding_output(0), + b_funding_satoshis: 0, + a_funding_satoshis: 0, }, &DuplicateEntropySource, ); @@ -1555,6 +1826,9 @@ mod tests { inputs_b: vec![], outputs_b: vec![], expect_error: Some((AbortReason::ReceivedTooManyTxAddOutputs, ErrorCulprit::NodeA)), + intended_new_funding_output: generate_funding_output(0), + b_funding_satoshis: 0, + a_funding_satoshis: 0, }); do_test_interactive_tx_constructor(TestSession { description: "Initiator sends an output below dust value", @@ -1565,6 +1839,9 @@ mod tests { inputs_b: vec![], outputs_b: vec![], expect_error: Some((AbortReason::BelowDustLimit, ErrorCulprit::NodeA)), + intended_new_funding_output: generate_funding_output(0), + b_funding_satoshis: 0, + a_funding_satoshis: 0, }); do_test_interactive_tx_constructor(TestSession { description: "Initiator sends an output above maximum sats allowed", @@ -1573,6 +1850,9 @@ mod tests { inputs_b: vec![], outputs_b: vec![], expect_error: Some((AbortReason::ExceededMaximumSatsAllowed, ErrorCulprit::NodeA)), + intended_new_funding_output: generate_funding_output(0), + b_funding_satoshis: 0, + a_funding_satoshis: 0, }); do_test_interactive_tx_constructor(TestSession { description: "Initiator sends an output without a witness program", @@ -1581,6 +1861,9 @@ mod tests { inputs_b: vec![], outputs_b: vec![], expect_error: Some((AbortReason::InvalidOutputScript, ErrorCulprit::NodeA)), + intended_new_funding_output: generate_funding_output(0), + b_funding_satoshis: 0, + a_funding_satoshis: 0, }); do_test_interactive_tx_constructor_with_entropy_source( TestSession { @@ -1591,6 +1874,9 @@ mod tests { inputs_b: vec![], outputs_b: vec![], expect_error: Some((AbortReason::DuplicateSerialId, ErrorCulprit::NodeA)), + intended_new_funding_output: generate_funding_output(0), + b_funding_satoshis: 0, + a_funding_satoshis: 0, }, &DuplicateEntropySource, ); @@ -1602,6 +1888,9 @@ mod tests { inputs_b: vec![], outputs_b: vec![], expect_error: Some((AbortReason::OutputsValueExceedsInputsValue, ErrorCulprit::NodeA)), + intended_new_funding_output: generate_funding_output(1_000_000), + b_funding_satoshis: 0, + a_funding_satoshis: 1_000_000, }); do_test_interactive_tx_constructor(TestSession { @@ -1614,6 +1903,9 @@ mod tests { AbortReason::ExceededNumberOfInputsOrOutputs, ErrorCulprit::Indeterminate, )), + intended_new_funding_output: generate_funding_output(0), + b_funding_satoshis: 0, + a_funding_satoshis: 0, }); do_test_interactive_tx_constructor(TestSession { description: "Peer contributed more than allowed number of outputs", @@ -1625,6 +1917,104 @@ mod tests { AbortReason::ExceededNumberOfInputsOrOutputs, ErrorCulprit::Indeterminate, )), + intended_new_funding_output: generate_funding_output(0), + b_funding_satoshis: 0, + a_funding_satoshis: 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_outputs(&[TestOutput::P2WSH(100_000)]), + inputs_b: vec![], + outputs_b: generate_outputs(&[TestOutput::P2WSH(100_000)]), + expect_error: Some((AbortReason::DuplicateFundingOutput, ErrorCulprit::NodeB)), + intended_new_funding_output: generate_funding_output(100_000), + b_funding_satoshis: 0, + a_funding_satoshis: 100_000, + }); + + // 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_outputs(&[TestOutput::P2WSH(1_000_000)]), + inputs_b: generate_inputs(&[TestOutput::P2WPKH(992_000)]), + outputs_b: vec![], + expect_error: None, + intended_new_funding_output: generate_funding_output(1_000_000), + b_funding_satoshis: 990_000, + a_funding_satoshis: 10_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_outputs(&[TestOutput::P2WSH(1_000_000)]), + expect_error: None, + intended_new_funding_output: generate_funding_output(1_000_000), + b_funding_satoshis: 990_000, + a_funding_satoshis: 10_000, + }); + + // 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_outputs(&[TestOutput::P2WSH(1_000_000)]), + inputs_b: generate_inputs(&[TestOutput::P2WPKH(12_000)]), + outputs_b: vec![], + expect_error: None, + intended_new_funding_output: generate_funding_output(1_000_000), + b_funding_satoshis: 10_000, + a_funding_satoshis: 990_000, + }); + + // They add the funding output, but we contribute most + do_test_interactive_tx_constructor(TestSession { + description: "Funding output by them, small contribution", + inputs_a: generate_inputs(&[TestOutput::P2WPKH(992_000)]), + outputs_a: vec![], + inputs_b: generate_inputs(&[TestOutput::P2WPKH(12_000)]), + outputs_b: generate_outputs(&[TestOutput::P2WSH(1_000_000)]), + expect_error: None, + intended_new_funding_output: generate_funding_output(1_000_000), + b_funding_satoshis: 10_000, + a_funding_satoshis: 990_000, + }); + + // 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_outputs(&[TestOutput::P2WSH(120_000)]), + inputs_b: generate_inputs(&[TestOutput::P2WPKH(50_000)]), + outputs_b: vec![], + expect_error: None, + intended_new_funding_output: generate_funding_output(120_000), + b_funding_satoshis: 0, + a_funding_satoshis: 120_000, + }); + + // 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_outputs(&[TestOutput::P2WSH(120_000)]), + inputs_b: generate_inputs(&[TestOutput::P2WPKH(85_000)]), + outputs_b: vec![], + expect_error: Some((AbortReason::OutputsValueExceedsInputsValue, ErrorCulprit::NodeA)), + intended_new_funding_output: generate_funding_output(120_000), + b_funding_satoshis: 0, + a_funding_satoshis: 120_000, }); }