diff --git a/payjoin/src/core/receive/common/mod.rs b/payjoin/src/core/receive/common/mod.rs new file mode 100644 index 000000000..8dbd7eb98 --- /dev/null +++ b/payjoin/src/core/receive/common/mod.rs @@ -0,0 +1,905 @@ +//! Common typestates and methods for both BIP 77 v2 and BIP 78 v1. +//! This module isn't meant to be exposed publicly, but for v1 and v2 +//! APIs to expose as relevant typestates. + +use std::cmp::{max, min}; + +use bitcoin::psbt::Psbt; +use bitcoin::secp256k1::rand::seq::SliceRandom; +use bitcoin::secp256k1::rand::{self, Rng}; +use bitcoin::{Amount, FeeRate, Script, TxIn, TxOut, Weight}; +use serde::{Deserialize, Serialize}; + +use super::error::{ + InputContributionError, InternalInputContributionError, InternalOutputSubstitutionError, + InternalSelectionError, +}; +use super::optional_parameters::Params; +use super::{InputPair, OutputSubstitutionError, ReplyableError, SelectionError}; +use crate::output_substitution::OutputSubstitution; +use crate::psbt::PsbtExt; +use crate::receive::{InternalPayloadError, Original, PsbtContext}; + +/// Typestate which the receiver may substitute or add outputs to. +/// +/// In addition to contributing new inputs to an existing PSBT, Payjoin allows the +/// receiver to substitute the original PSBT's outputs to potentially preserve privacy and batch transfers. +/// The receiver does not have to limit themselves to the address shared with the sender in the +/// original Payjoin URI, and can make substitutions of the existing outputs in the proposal. +/// +/// Call [`Self::commit_outputs`] to proceed. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct WantsOutputs { + original_psbt: Psbt, + payjoin_psbt: Psbt, + params: Params, + change_vout: usize, + owned_vouts: Vec, +} + +impl WantsOutputs { + /// Create a new [`WantsOutputs`] typestate from an [`Original`] typestate and a list of + /// owned outputs. + /// + /// The first output in the `owned_vouts` list is used as the `change_vout`. + pub(crate) fn new(original: Original, owned_vouts: Vec) -> Self { + Self { + original_psbt: original.psbt.clone(), + payjoin_psbt: original.psbt, + params: original.params, + change_vout: owned_vouts[0], + owned_vouts, + } + } + + /// Returns whether the receiver is allowed to substitute original outputs or not. + pub fn output_substitution(&self) -> OutputSubstitution { self.params.output_substitution } + + /// Substitute the receiver output script with the provided script. + pub fn substitute_receiver_script( + self, + output_script: &Script, + ) -> Result { + let output_value = self.original_psbt.unsigned_tx.output[self.change_vout].value; + let outputs = [TxOut { value: output_value, script_pubkey: output_script.into() }]; + self.replace_receiver_outputs(outputs, output_script) + } + + /// Replaces **all** receiver outputs with the one or more provided `replacement_outputs`, and + /// sets up the passed `drain_script` as the receiver-owned output which might have its value + /// adjusted based on the modifications the receiver makes in the subsequent typestates. + /// + /// The sender's outputs are not touched. Existing receiver outputs will be replaced with the + /// outputs in the `replacement_outputs` argument. The number of replacement outputs should + /// match or exceed the number of receiver outputs in the original proposal PSBT. + /// + /// The drain script is the receiver script which will have its value adjusted based on the + /// modifications the receiver makes on the transaction in the subsequent typestates. For + /// example, if the receiver adds their own input, then the drain script output will have its + /// value increased by the same amount. Or if an output needs to have its value reduced to + /// account for fees, the value of the output for this script will be reduced. + pub fn replace_receiver_outputs( + self, + replacement_outputs: impl IntoIterator, + drain_script: &Script, + ) -> Result { + let mut payjoin_psbt = self.original_psbt.clone(); + let mut outputs = vec![]; + let mut replacement_outputs: Vec = replacement_outputs.into_iter().collect(); + let mut rng = rand::thread_rng(); + // Substitute the existing receiver outputs, keeping the sender/receiver output ordering + for (i, original_output) in self.original_psbt.unsigned_tx.output.iter().enumerate() { + if self.owned_vouts.contains(&i) { + // Receiver output: substitute in-place a provided replacement output + if replacement_outputs.is_empty() { + return Err(InternalOutputSubstitutionError::NotEnoughOutputs.into()); + } + match replacement_outputs + .iter() + .position(|txo| txo.script_pubkey == original_output.script_pubkey) + { + // Select an output with the same address if one was provided + Some(pos) => { + let txo = replacement_outputs.swap_remove(pos); + if self.output_substitution() == OutputSubstitution::Disabled + && txo.value < original_output.value + { + return Err( + InternalOutputSubstitutionError::DecreasedValueWhenDisabled.into(), + ); + } + outputs.push(txo); + } + // Otherwise randomly select one of the replacement outputs + None => { + if self.output_substitution() == OutputSubstitution::Disabled { + return Err( + InternalOutputSubstitutionError::ScriptPubKeyChangedWhenDisabled + .into(), + ); + } + let index = rng.gen_range(0..replacement_outputs.len()); + let txo = replacement_outputs.swap_remove(index); + outputs.push(txo); + } + } + } else { + // Sender output: leave it as is + outputs.push(original_output.clone()); + } + } + // Insert all remaining outputs at random indices for privacy + interleave_shuffle(&mut outputs, &mut replacement_outputs, &mut rng); + // Identify the receiver output that will be used for change and fees + let change_vout = outputs.iter().position(|txo| txo.script_pubkey == *drain_script); + // Update the payjoin PSBT outputs + payjoin_psbt.outputs = vec![Default::default(); outputs.len()]; + payjoin_psbt.unsigned_tx.output = outputs; + Ok(Self { + original_psbt: self.original_psbt, + payjoin_psbt, + params: self.params, + change_vout: change_vout.ok_or(InternalOutputSubstitutionError::InvalidDrainScript)?, + owned_vouts: self.owned_vouts, + }) + } + + /// Commits the outputs as final, and moves on to the next typestate. + /// + /// Outputs cannot be modified after this function is called. + pub fn commit_outputs(self) -> WantsInputs { + WantsInputs { + original_psbt: self.original_psbt, + payjoin_psbt: self.payjoin_psbt, + params: self.params, + change_vout: self.change_vout, + receiver_inputs: vec![], + } + } +} + +/// Shuffles `new` vector, then interleaves its elements with those from `original`, +/// maintaining the relative order in `original` but randomly inserting elements from `new`. +/// +/// The combined result replaces the contents of `original`. +fn interleave_shuffle(original: &mut Vec, new: &mut [T], rng: &mut R) { + // Shuffle the substitute_outputs + new.shuffle(rng); + // Create a new vector to store the combined result + let mut combined = Vec::with_capacity(original.len() + new.len()); + // Initialize indices + let mut original_index = 0; + let mut new_index = 0; + // Interleave elements + while original_index < original.len() || new_index < new.len() { + if original_index < original.len() && (new_index >= new.len() || rng.gen_bool(0.5)) { + combined.push(original[original_index].clone()); + original_index += 1; + } else { + combined.push(new[new_index].clone()); + new_index += 1; + } + } + *original = combined; +} + +/// Typestate for a checked proposal which the receiver may contribute inputs to. +/// +/// Call [`Self::commit_inputs`] to proceed. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct WantsInputs { + original_psbt: Psbt, + payjoin_psbt: Psbt, + params: Params, + change_vout: usize, + receiver_inputs: Vec, +} + +impl WantsInputs { + /// Selects and returns an input from `candidate_inputs` which will preserve the receiver's privacy by + /// avoiding the Unnecessary Input Heuristic 2 (UIH2) outlined in [Unnecessary Input + /// Heuristics and PayJoin Transactions by Ghesmati et al. (2022)](https://eprint.iacr.org/2022/589). + /// + /// Privacy preservation is only supported for 2-output transactions. If the PSBT has more than + /// 2 outputs or if none of the candidates are suitable for avoiding UIH2, this function + /// defaults to the first candidate in `candidate_inputs` list. + pub fn try_preserving_privacy( + &self, + candidate_inputs: impl IntoIterator, + ) -> Result { + let mut candidate_inputs = candidate_inputs.into_iter().peekable(); + + self.avoid_uih(&mut candidate_inputs) + .or_else(|_| self.select_first_candidate(&mut candidate_inputs)) + } + + /// Returns the candidate input which avoids the UIH2 defined in [Unnecessary Input + /// Heuristics and PayJoin Transactions by Ghesmati et al. (2022)](https://eprint.iacr.org/2022/589). + /// + /// Based on the paper, we are looking for the candidate input which, when added to the + /// transaction with 2 existing outputs, results in the minimum input amount to be lower than the minimum + /// output amount. Note that when calculating the minimum output amount, we consider the + /// post-contribution amounts, and expect the output which pays to the receiver to have its + /// value increased by the amount of the candidate input. + /// + /// Errors if the transaction does not have exactly 2 outputs. + pub(crate) fn avoid_uih( + &self, + candidate_inputs: impl IntoIterator, + ) -> Result { + if self.payjoin_psbt.outputs.len() != 2 { + return Err(InternalSelectionError::UnsupportedOutputLength.into()); + } + + let min_out_sats = self + .payjoin_psbt + .unsigned_tx + .output + .iter() + .map(|output| output.value) + .min() + .unwrap_or(Amount::MAX_MONEY); + + let min_in_sats = self + .payjoin_psbt + .input_pairs() + .filter_map(|input| input.previous_txout().ok().map(|txo| txo.value)) + .min() + .unwrap_or(Amount::MAX_MONEY); + + let prior_payment_sats = self.payjoin_psbt.unsigned_tx.output[self.change_vout].value; + + for input_pair in candidate_inputs { + let candidate_sats = input_pair.previous_txout().value; + let candidate_min_out = min(min_out_sats, prior_payment_sats + candidate_sats); + let candidate_min_in = min(min_in_sats, candidate_sats); + + if candidate_min_in > candidate_min_out { + // The candidate avoids UIH2 but conforms to UIH1: Optimal change heuristic. + // It implies the smallest output is the sender's change address. + return Ok(input_pair); + } + } + + // No suitable privacy preserving selection found + Err(InternalSelectionError::NotFound.into()) + } + + /// Returns the first candidate input in the provided list or errors if the list is empty. + fn select_first_candidate( + &self, + candidate_inputs: impl IntoIterator, + ) -> Result { + candidate_inputs.into_iter().next().ok_or(InternalSelectionError::Empty.into()) + } + + /// Contributes the provided list of inputs to the transaction at random indices. If the total input + /// amount exceeds the total output amount after the contribution, adds all excess amount to + /// the receiver change output. + pub fn contribute_inputs( + self, + inputs: impl IntoIterator, + ) -> Result { + let mut payjoin_psbt = self.payjoin_psbt.clone(); + // The payjoin proposal must not introduce mixed input sequence numbers + let original_sequence = self + .original_psbt + .unsigned_tx + .input + .first() + .map(|input| input.sequence) + .unwrap_or_default(); + + let inputs = inputs.into_iter().collect::>(); + + // Insert contributions at random indices for privacy + let mut rng = rand::thread_rng(); + let mut receiver_input_amount = Amount::ZERO; + for input_pair in inputs.clone() { + receiver_input_amount += input_pair.previous_txout().value; + let index = rng.gen_range(0..=self.payjoin_psbt.unsigned_tx.input.len()); + payjoin_psbt.inputs.insert(index, input_pair.psbtin); + payjoin_psbt + .unsigned_tx + .input + .insert(index, TxIn { sequence: original_sequence, ..input_pair.txin }); + } + + // Add the receiver change amount to the receiver change output, if applicable + let receiver_min_input_amount = self.receiver_min_input_amount(); + if receiver_input_amount >= receiver_min_input_amount { + let change_amount = receiver_input_amount - receiver_min_input_amount; + payjoin_psbt.unsigned_tx.output[self.change_vout].value += change_amount; + } else { + return Err(InternalInputContributionError::ValueTooLow.into()); + } + + let mut receiver_inputs = self.receiver_inputs; + receiver_inputs.extend(inputs); + + Ok(WantsInputs { + original_psbt: self.original_psbt, + payjoin_psbt, + params: self.params, + change_vout: self.change_vout, + receiver_inputs, + }) + } + + // Compute the minimum amount that the receiver must contribute to the transaction as input. + fn receiver_min_input_amount(&self) -> Amount { + let output_amount = self + .payjoin_psbt + .unsigned_tx + .output + .iter() + .fold(Amount::ZERO, |acc, output| acc + output.value); + let original_output_amount = self + .original_psbt + .unsigned_tx + .output + .iter() + .fold(Amount::ZERO, |acc, output| acc + output.value); + output_amount.checked_sub(original_output_amount).unwrap_or(Amount::ZERO) + } + + /// Commits the inputs as final, and moves on to the next typestate. + /// + /// Inputs cannot be modified after this function is called. + pub fn commit_inputs(self) -> WantsFeeRange { + WantsFeeRange { + original_psbt: self.original_psbt, + payjoin_psbt: self.payjoin_psbt, + params: self.params, + change_vout: self.change_vout, + receiver_inputs: self.receiver_inputs, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct WantsFeeRange { + original_psbt: Psbt, + payjoin_psbt: Psbt, + params: Params, + change_vout: usize, + receiver_inputs: Vec, +} + +impl WantsFeeRange { + fn apply_fee( + &mut self, + min_fee_rate: Option, + max_effective_fee_rate: Option, + ) -> Result<&Psbt, InternalPayloadError> { + let min_fee_rate = min_fee_rate.unwrap_or(FeeRate::BROADCAST_MIN); + log::trace!("min_fee_rate: {min_fee_rate:?}"); + log::trace!("params.min_fee_rate: {:?}", self.params.min_fee_rate); + let min_fee_rate = max(min_fee_rate, self.params.min_fee_rate); + log::debug!("min_fee_rate: {min_fee_rate:?}"); + + let max_fee_rate = max_effective_fee_rate.unwrap_or(FeeRate::BROADCAST_MIN); + + // If the sender specified a fee contribution, the receiver is allowed to decrease the + // sender's fee output to pay for additional input fees. Any fees in excess of + // `max_additional_fee_contribution` must be covered by the receiver. + let input_contribution_weight = self.additional_input_weight()?; + let additional_fee = input_contribution_weight * min_fee_rate; + log::trace!("additional_fee: {additional_fee}"); + let mut receiver_additional_fee = additional_fee; + if additional_fee >= Amount::ONE_SAT { + log::trace!( + "self.params.additional_fee_contribution: {:?}", + self.params.additional_fee_contribution + ); + if let Some((max_additional_fee_contribution, additional_fee_output_index)) = + self.params.additional_fee_contribution + { + // Find the sender's specified output in the original psbt. + // This step is necessary because the sender output may have shifted if new + // receiver outputs were added to the payjoin psbt. + let sender_fee_output = + &self.original_psbt.unsigned_tx.output[additional_fee_output_index]; + // Find the index of that output in the payjoin psbt + let sender_fee_vout = self + .payjoin_psbt + .unsigned_tx + .output + .iter() + .position(|txo| txo.script_pubkey == sender_fee_output.script_pubkey) + .expect("Sender output is missing from payjoin PSBT"); + // Determine the additional amount that the sender will pay in fees + let sender_additional_fee = min(max_additional_fee_contribution, additional_fee); + log::trace!("sender_additional_fee: {sender_additional_fee}"); + // Remove additional miner fee from the sender's specified output + self.payjoin_psbt.unsigned_tx.output[sender_fee_vout].value -= + sender_additional_fee; + receiver_additional_fee -= sender_additional_fee; + } + } + + // The sender's fee contribution can only be used to pay for additional input weight, so + // any additional outputs must be paid for by the receiver. + let output_contribution_weight = self.additional_output_weight(); + receiver_additional_fee += output_contribution_weight * min_fee_rate; + log::trace!("receiver_additional_fee: {receiver_additional_fee}"); + // Ensure that the receiver does not pay more in fees + // than they would by building a separate transaction at max_effective_fee_rate instead. + let max_fee = (input_contribution_weight + output_contribution_weight) * max_fee_rate; + log::trace!("max_fee: {max_fee}"); + if receiver_additional_fee > max_fee { + let proposed_fee_rate = + receiver_additional_fee / (input_contribution_weight + output_contribution_weight); + return Err(InternalPayloadError::FeeTooHigh(proposed_fee_rate, max_fee_rate)); + } + if receiver_additional_fee >= Amount::ONE_SAT { + // Remove additional miner fee from the receiver's specified output + self.payjoin_psbt.unsigned_tx.output[self.change_vout].value -= receiver_additional_fee; + } + Ok(&self.payjoin_psbt) + } + + /// Calculate the additional input weight contributed by the receiver. + fn additional_input_weight(&self) -> Result { + Ok(self.receiver_inputs.iter().map(|input_pair| input_pair.expected_weight).sum()) + } + + /// Calculate the additional output weight contributed by the receiver. + fn additional_output_weight(&self) -> Weight { + let payjoin_outputs_weight = self + .payjoin_psbt + .unsigned_tx + .output + .iter() + .fold(Weight::ZERO, |acc, txo| acc + txo.weight()); + let original_outputs_weight = self + .original_psbt + .unsigned_tx + .output + .iter() + .fold(Weight::ZERO, |acc, txo| acc + txo.weight()); + let output_contribution_weight = payjoin_outputs_weight - original_outputs_weight; + log::trace!("output_contribution_weight : {output_contribution_weight}"); + output_contribution_weight + } + + /// Applies additional fee contribution now that the receiver has contributed inputs + /// and may have added new outputs. + /// + /// How much the receiver ends up paying for fees depends on how much the sender stated they + /// were willing to pay in the parameters of the original proposal. For additional + /// inputs, fees will be subtracted from the sender's outputs as much as possible until we hit + /// the limit the sender specified in the Payjoin parameters. Any remaining fees for the new inputs + /// will be then subtracted from the change output of the receiver. + /// Fees for additional outputs are always subtracted from the receiver's outputs. + /// + /// `max_effective_fee_rate` is the maximum effective fee rate that the receiver is + /// willing to pay for their own input/output contributions. A `max_effective_fee_rate` + /// of zero indicates that the receiver is not willing to pay any additional + /// fees. Errors if the final effective fee rate exceeds `max_effective_fee_rate`. + /// + /// If not provided, `min_fee_rate` and `max_effective_fee_rate` default to the + /// minimum possible relay fee. + /// + /// The minimum effective fee limit is the highest of the minimum limit set by the sender in + /// the original proposal parameters and the limit passed in the `min_fee_rate` parameter. + pub(super) fn apply_fee_to_psbt_context( + mut self, + min_fee_rate: Option, + max_effective_fee_rate: Option, + ) -> Result { + let psbt = self.apply_fee(min_fee_rate, max_effective_fee_rate)?.clone(); + Ok(PsbtContext { original_psbt: self.original_psbt, payjoin_psbt: psbt }) + } +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use bitcoin::absolute::{LockTime, Time}; + use bitcoin::bip32::{DerivationPath, Fingerprint, Xpriv, Xpub}; + use bitcoin::hashes::Hash; + use bitcoin::key::rand::rngs::StdRng; + use bitcoin::key::rand::SeedableRng; + use bitcoin::psbt::Input; + use bitcoin::secp256k1::Secp256k1; + use bitcoin::taproot::LeafVersion; + use bitcoin::{ + Amount, Network, OutPoint, PubkeyHash, ScriptBuf, Sequence, TapLeafHash, Transaction, TxIn, + TxOut, Weight, + }; + use payjoin_test_utils::{DUMMY20, RECEIVER_INPUT_CONTRIBUTION}; + + use super::*; + use crate::receive::tests::original_from_test_vector; + + #[test] + fn empty_candidates_inputs() { + let original = original_from_test_vector(); + let wants_inputs = WantsOutputs::new(original, vec![0]).commit_outputs(); + let empty_candidate_inputs: Vec = vec![]; + let result = wants_inputs.try_preserving_privacy(empty_candidate_inputs); + assert_eq!( + result.unwrap_err(), + SelectionError::from(InternalSelectionError::Empty), + "try_preserving_privacy should fail with empty candidate inputs" + ); + } + + #[test] + fn test_pjos_disabled() { + let mut original = original_from_test_vector(); + original.params.output_substitution = OutputSubstitution::Disabled; + let wants_outputs = WantsOutputs::new(original, vec![0]); + let script_pubkey = &wants_outputs.original_psbt.unsigned_tx.output + [wants_outputs.change_vout] + .script_pubkey; + + let output_value = + wants_outputs.original_psbt.unsigned_tx.output[wants_outputs.change_vout].value; + let outputs = vec![TxOut { value: output_value, script_pubkey: script_pubkey.clone() }]; + let unchanged_amount = + wants_outputs.clone().replace_receiver_outputs(outputs, script_pubkey.as_script()); + assert!( + unchanged_amount.is_ok(), + "Not touching the receiver output amount is always allowed" + ); + assert_ne!(wants_outputs.payjoin_psbt, unchanged_amount.unwrap().payjoin_psbt); + + let output_value = + wants_outputs.original_psbt.unsigned_tx.output[wants_outputs.change_vout].value + + Amount::ONE_SAT; + let outputs = vec![TxOut { value: output_value, script_pubkey: script_pubkey.clone() }]; + let increased_amount = + wants_outputs.clone().replace_receiver_outputs(outputs, script_pubkey.as_script()); + assert!( + increased_amount.is_ok(), + "Increasing the receiver output amount is always allowed" + ); + assert_ne!(wants_outputs.payjoin_psbt, increased_amount.unwrap().payjoin_psbt); + + let output_value = + wants_outputs.original_psbt.unsigned_tx.output[wants_outputs.change_vout].value + - Amount::ONE_SAT; + let outputs = vec![TxOut { value: output_value, script_pubkey: script_pubkey.clone() }]; + let decreased_amount = + wants_outputs.clone().replace_receiver_outputs(outputs, script_pubkey.as_script()); + assert_eq!( + decreased_amount.unwrap_err(), + OutputSubstitutionError::from( + InternalOutputSubstitutionError::DecreasedValueWhenDisabled + ), + "Payjoin receiver amount has been decreased and should error" + ); + + let script = Script::new(); + let replace_receiver_script_pubkey = wants_outputs.substitute_receiver_script(script); + assert_eq!( + replace_receiver_script_pubkey.unwrap_err(), + OutputSubstitutionError::from( + InternalOutputSubstitutionError::ScriptPubKeyChangedWhenDisabled + ), + "Payjoin receiver script pubkey has been modified and should error" + ); + } + + #[test] + fn test_avoid_uih_one_output() { + let original = original_from_test_vector(); + let proposal_psbt = Psbt::from_str(RECEIVER_INPUT_CONTRIBUTION).unwrap(); + let input = InputPair::new( + proposal_psbt.unsigned_tx.input[1].clone(), + proposal_psbt.inputs[1].clone(), + None, + ) + .unwrap(); + let input_iter = [input].into_iter(); + let mut payjoin = WantsOutputs::new(original, vec![0]) + .commit_outputs() + .contribute_inputs(input_iter.clone()) + .expect("Failed to contribute inputs"); + + payjoin.payjoin_psbt.outputs.pop(); + let avoid_uih = payjoin.avoid_uih(input_iter); + assert_eq!( + avoid_uih.unwrap_err(), + SelectionError::from(InternalSelectionError::UnsupportedOutputLength), + "Payjoin below minimum allowed outputs for avoid uih and should error" + ); + } + + #[test] + fn test_interleave_shuffle() { + let mut original1 = vec![1, 2, 3]; + let mut original2 = original1.clone(); + let mut original3 = original1.clone(); + let mut new1 = vec![4, 5, 6]; + let mut new2 = new1.clone(); + let mut new3 = new1.clone(); + let mut rng1 = StdRng::seed_from_u64(123); + let mut rng2 = StdRng::seed_from_u64(234); + let mut rng3 = StdRng::seed_from_u64(345); + // Operate on the same data multiple times with different RNG seeds. + interleave_shuffle(&mut original1, &mut new1, &mut rng1); + interleave_shuffle(&mut original2, &mut new2, &mut rng2); + interleave_shuffle(&mut original3, &mut new3, &mut rng3); + // The result should be different for each seed + // and the relative ordering from `original` always preserved/ + assert_eq!(original1, vec![1, 6, 2, 5, 4, 3]); + assert_eq!(original2, vec![1, 5, 4, 2, 6, 3]); + assert_eq!(original3, vec![4, 5, 1, 2, 6, 3]); + } + + #[test] + fn test_multiple_contribute_inputs() { + let original = original_from_test_vector(); + let wants_inputs = WantsOutputs::new(original, vec![0]).commit_outputs(); + let txout = TxOut { + value: Amount::from_sat(123), + script_pubkey: ScriptBuf::new_p2pkh(&PubkeyHash::from_byte_array(DUMMY20)), + }; + let tx = Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::Seconds(Time::MIN), + input: vec![], + output: vec![txout.clone()], + }; + let ot1 = OutPoint { txid: tx.compute_txid(), vout: 0 }; + let ot2 = OutPoint { txid: tx.compute_txid(), vout: 1 }; + + let input_pair_1 = InputPair::new( + TxIn { previous_output: ot1, sequence: Sequence::MAX, ..Default::default() }, + Input { witness_utxo: Some(txout.clone()), ..Default::default() }, + None, + ) + .unwrap(); + let input_pair_2 = InputPair::new( + TxIn { previous_output: ot2, sequence: Sequence::MAX, ..Default::default() }, + Input { witness_utxo: Some(txout), ..Default::default() }, + None, + ) + .unwrap(); + + let wants_inputs = wants_inputs.contribute_inputs(vec![input_pair_1.clone()]).unwrap(); + assert_eq!(wants_inputs.receiver_inputs.len(), 1); + assert_eq!(wants_inputs.receiver_inputs[0], input_pair_1); + // Contribute the same input again, and a new input. + // TODO: if we ever decide to fix contribute duplicate inputs, we need to update this test. + let wants_inputs = wants_inputs + .contribute_inputs(vec![input_pair_2.clone(), input_pair_1.clone()]) + .unwrap(); + assert_eq!(wants_inputs.receiver_inputs.len(), 3); + assert_eq!(wants_inputs.receiver_inputs[0], input_pair_1); + assert_eq!(wants_inputs.receiver_inputs[1], input_pair_2); + assert_eq!(wants_inputs.receiver_inputs[2], input_pair_1); + } + + #[test] + fn sender_specifies_excessive_fee_rate() { + let original = original_from_test_vector(); + let mut original_params = original.params.clone(); + assert_eq!(original.psbt_fee_rate().unwrap().to_sat_per_vb_floor(), 2); + // Specify excessive fee rate in sender params + original_params.min_fee_rate = FeeRate::from_sat_per_vb_unchecked(1000); + let updated_original = Original { psbt: original.psbt, params: original_params }; + + let proposal_psbt = Psbt::from_str(RECEIVER_INPUT_CONTRIBUTION).unwrap(); + let input = InputPair::new( + proposal_psbt.unsigned_tx.input[1].clone(), + proposal_psbt.inputs[1].clone(), + None, + ) + .unwrap(); + let mut payjoin = WantsOutputs::new(updated_original, vec![0]) + .commit_outputs() + .contribute_inputs(vec![input]) + .expect("Failed to contribute inputs") + .commit_inputs(); + let additional_output = TxOut { + value: Amount::ZERO, + script_pubkey: payjoin.original_psbt.unsigned_tx.output[0].script_pubkey.clone(), + }; + payjoin.payjoin_psbt.unsigned_tx.output.push(additional_output); + let mut payjoin_clone = payjoin.clone(); + let psbt = payjoin.apply_fee(None, Some(FeeRate::from_sat_per_vb_unchecked(1000))); + assert!(psbt.is_ok(), "Payjoin should be a valid PSBT"); + let psbt = payjoin_clone.apply_fee(None, Some(FeeRate::from_sat_per_vb_unchecked(995))); + match psbt { + Err(InternalPayloadError::FeeTooHigh(proposed, max)) => { + assert_eq!(FeeRate::from_str("249630").unwrap(), proposed); + assert_eq!(FeeRate::from_sat_per_vb_unchecked(995), max); + } + _ => panic!( + "Payjoin exceeds receiver fee preference and should error or unexpected error type" + ), + } + } + + #[test] + fn additional_input_weight_matches_known_weight() { + // All expected input weights pulled from: + // https://bitcoin.stackexchange.com/questions/84004/how-do-virtual-size-stripped-size-and-raw-size-compare-between-legacy-address-f#84006 + // Input weight for a single P2PKH (legacy) receiver input + let p2pkh_proposal = WantsFeeRange { + original_psbt: Psbt::from_str("cHNidP8BAHECAAAAAb2qhegy47hqffxh/UH5Qjd/G3sBH6cW2QSXZ86nbY3nAAAAAAD9////AhXKBSoBAAAAFgAU4TiLFD14YbpddFVrZa3+Zmz96yQQJwAAAAAAABYAFB4zA2o+5MsNRT/j+0twLi5VbwO9AAAAAAABAIcCAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////wMBSgD/////AgDyBSoBAAAAGXapFGUxpU6cGldVpjUm9rV2B+jTlphDiKwAAAAAAAAAACZqJKohqe3i9hw/cdHe/T+pmd+jaVN1XGkGiXmZYrSL69g2l06M+QAAAAABB2pHMEQCIGsOxO/bBv20bd68sBnEU3cxHR8OxEcUroL3ENhhjtN3AiB+9yWuBGKXu41hcfO4KP7IyLLEYc6j8hGowmAlCPCMPAEhA6WNSN4CqJ9F+42YKPlIFN0wJw7qawWbdelGRMkAbBRnACICAsdIAjsfMLKgfL2J9rfIa8yKdO1BOpSGRIFbFMBdTsc9GE4roNNUAACAAQAAgAAAAIABAAAAAAAAAAAA").unwrap(), + payjoin_psbt: Psbt::from_str("cHNidP8BAJoCAAAAAtTRxwAtk38fRMP3ffdKkIi5r+Ss9AjaO8qEv+eQ/ho3AAAAAAD9////vaqF6DLjuGp9/GH9QflCN38bewEfpxbZBJdnzqdtjecAAAAAAP3///8CgckFKgEAAAAWABThOIsUPXhhul10VWtlrf5mbP3rJBAZBioBAAAAFgAUiDIby0wSbj1kv3MlvwoEKw3vNZUAAAAAAAEAhwIAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////AwFoAP////8CAPIFKgEAAAAZdqkUPXhu3I6D9R0wUpvTvvUm+VGNcNuIrAAAAAAAAAAAJmokqiGp7eL2HD9x0d79P6mZ36NpU3VcaQaJeZlitIvr2DaXToz5AAAAAAEBIgDyBSoBAAAAGXapFD14btyOg/UdMFKb0771JvlRjXDbiKwBB2pHMEQCIGzKy8QfhHoAY0+LZCpQ7ZOjyyXqaSBnr89hH3Eg/xsGAiB3n8hPRuXCX/iWtURfXoJNUFu3sLeQVFf1dDFCZPN0dAEhA8rTfrwcq6dEBSNOrUfNb8+dm7q77vCtfdOmWx0HfajRAAEAhwIAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////AwFKAP////8CAPIFKgEAAAAZdqkUZTGlTpwaV1WmNSb2tXYH6NOWmEOIrAAAAAAAAAAAJmokqiGp7eL2HD9x0d79P6mZ36NpU3VcaQaJeZlitIvr2DaXToz5AAAAAAAAAA==").unwrap(), + params: Params::default(), + change_vout: 0, + receiver_inputs: vec![ + InputPair::new( + TxIn{ + previous_output: OutPoint::from_str("371afe90e7bf84ca3bda08f4ace4afb988904af77df7c3441f7f932d00c7d1d4:0").unwrap(), + ..Default::default() + }, Input { + witness_utxo: Some(TxOut { + value: Amount::from_sat(5_000_000_000), + script_pubkey: ScriptBuf::from_hex("76a9143d786edc8e83f51d30529bd3bef526f9518d70db88ac").unwrap(), + }), + ..Default::default() + }, None) + .unwrap()], + }; + assert_eq!( + p2pkh_proposal.additional_input_weight().expect("should calculate input weight"), + Weight::from_wu(592) + ); + + // Input weight for a single nested P2WPKH (nested segwit) receiver input + let nested_p2wpkh_proposal = WantsFeeRange { + original_psbt: Psbt::from_str("cHNidP8BAHECAAAAAeOsT9cRWRz3te+bgmtweG1vDLkdSH4057NuoodDNPFWAAAAAAD9////AhAnAAAAAAAAFgAUtp3bPFM/YWThyxD5Cc9OR4mb8tdMygUqAQAAABYAFODlplDoE6EGlZvmqoUngBgsu8qCAAAAAAABAIUCAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////wMBZwD/////AgDyBSoBAAAAF6kU2JnIn4Mmcb5kuF3EYeFei8IB43qHAAAAAAAAAAAmaiSqIant4vYcP3HR3v0/qZnfo2lTdVxpBol5mWK0i+vYNpdOjPkAAAAAAQEgAPIFKgEAAAAXqRTYmcifgyZxvmS4XcRh4V6LwgHjeocBBxcWABSPGoPK1yl60X4Z9OfA7IQPUWCgVwEIawJHMEQCICZG3s2cbulPnLTvK4TwlKhsC+cem8tD2GjZZ3eMJD7FAiADh/xwv0ib8ksOrj1M27DYLiw7WFptxkMkE2YgiNMRVgEhAlDMm5DA8kU+QGiPxEWUyV1S8+XGzUOepUOck257ZOhkAAAiAgP+oMbeca66mt+UtXgHm6v/RIFEpxrwG7IvPDim5KWHpBgfVHrXVAAAgAEAAIAAAACAAQAAAAAAAAAA").unwrap(), + payjoin_psbt: Psbt::from_str("cHNidP8BAJoCAAAAAuXYOTUaVRiB8cPPhEXzcJ72/SgZOPEpPx5pkG0fNeGCAAAAAAD9////46xP1xFZHPe175uCa3B4bW8MuR1IfjTns26ih0M08VYAAAAAAP3///8CEBkGKgEAAAAWABQHuuu4H4fbQWV51IunoJLUtmMTfEzKBSoBAAAAFgAU4OWmUOgToQaVm+aqhSeAGCy7yoIAAAAAAAEBIADyBSoBAAAAF6kUQ4BssmVBS3r0s95c6dl1DQCHCR+HAQQWABQbDc333XiiOeEXroP523OoYNb1aAABAIUCAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////wMBZwD/////AgDyBSoBAAAAF6kU2JnIn4Mmcb5kuF3EYeFei8IB43qHAAAAAAAAAAAmaiSqIant4vYcP3HR3v0/qZnfo2lTdVxpBol5mWK0i+vYNpdOjPkAAAAAAQEgAPIFKgEAAAAXqRTYmcifgyZxvmS4XcRh4V6LwgHjeocBBxcWABSPGoPK1yl60X4Z9OfA7IQPUWCgVwEIawJHMEQCICZG3s2cbulPnLTvK4TwlKhsC+cem8tD2GjZZ3eMJD7FAiADh/xwv0ib8ksOrj1M27DYLiw7WFptxkMkE2YgiNMRVgEhAlDMm5DA8kU+QGiPxEWUyV1S8+XGzUOepUOck257ZOhkAAAA").unwrap(), + params: Params::default(), + change_vout: 0, + receiver_inputs: vec![ + InputPair::new( + TxIn { + previous_output: OutPoint::from_str("82e1351f6d90691e3f29f1381928fdf69e70f34584cfc3f18118551a3539d8e5:0").unwrap(), + ..Default::default() + }, + Input { + witness_utxo: Some(TxOut { + value: Amount::from_sat(5_000_000_000), + script_pubkey: ScriptBuf::from_hex("a91443806cb265414b7af4b3de5ce9d9750d0087091f87").unwrap(), + }), + redeem_script: Some(ScriptBuf::from_hex("00141b0dcdf7dd78a239e117ae83f9db73a860d6f568").unwrap()), + ..Default::default() + }, None) + .unwrap()], + }; + assert_eq!( + nested_p2wpkh_proposal + .additional_input_weight() + .expect("should calculate input weight"), + Weight::from_wu(364) + ); + + // Input weight for a single P2WPKH (native segwit) receiver input + let p2wpkh_proposal = WantsFeeRange { + original_psbt: Psbt::from_str("cHNidP8BAHECAAAAASom13OiXZIr3bKk+LtUndZJYqdHQQU8dMs1FZ93IctIAAAAAAD9////AmPKBSoBAAAAFgAU6H98YM9NE1laARQ/t9/90nFraf4QJwAAAAAAABYAFBPJFmYuJBsrIaBBp9ur98pMSKxhAAAAAAABAIQCAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////wMBWwD/////AgDyBSoBAAAAFgAUjTJXmC73n+URSNdfgbS6Oa6JyQYAAAAAAAAAACZqJKohqe3i9hw/cdHe/T+pmd+jaVN1XGkGiXmZYrSL69g2l06M+QAAAAABAR8A8gUqAQAAABYAFI0yV5gu95/lEUjXX4G0ujmuickGAQhrAkcwRAIgUqbHS0difIGTRwN56z2/EiqLQFWerfJspyjuwsGSCXcCIA3IRTu8FVgniU5E4gecAMeegVnlTbTVfFyusWhQ2kVVASEDChVRm26KidHNWLdCLBTq5jspGJr+AJyyMqmUkvPkwFsAIgIDeBqmRB3ESjFWIp+wUXn/adGZU3kqWGjdkcnKpk8bAyUY94v8N1QAAIABAACAAAAAgAEAAAAAAAAAAAA=").unwrap(), + payjoin_psbt: Psbt::from_str("cHNidP8BAJoCAAAAAiom13OiXZIr3bKk+LtUndZJYqdHQQU8dMs1FZ93IctIAAAAAAD9////NG21aH8Vat3thaVmPvWDV/lvRmymFHeePcfUjlyngHIAAAAAAP3///8CH8oFKgEAAAAWABTof3xgz00TWVoBFD+33/3ScWtp/hAZBioBAAAAFgAU1mbnqky3bMxfmm0OgFaQCAs5fsoAAAAAAAEAhAIAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////AwFbAP////8CAPIFKgEAAAAWABSNMleYLvef5RFI11+BtLo5ronJBgAAAAAAAAAAJmokqiGp7eL2HD9x0d79P6mZ36NpU3VcaQaJeZlitIvr2DaXToz5AAAAAAEBHwDyBSoBAAAAFgAUjTJXmC73n+URSNdfgbS6Oa6JyQYAAQCEAgAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP////8DAWcA/////wIA8gUqAQAAABYAFJFtkfHTt3y1EDMaN6CFjjNWtpCRAAAAAAAAAAAmaiSqIant4vYcP3HR3v0/qZnfo2lTdVxpBol5mWK0i+vYNpdOjPkAAAAAAQEfAPIFKgEAAAAWABSRbZHx07d8tRAzGjeghY4zVraQkQEIawJHMEQCIDTC49IB9AnItqd8zy5RDc05f2ApBAfJ5x4zYfj3bsD2AiAQvvSt5ipScHcUwdlYB9vFnEi68hmh55M5a5e+oWvxMAEhAqErVSVulFb97/r5KQryOS1Xgghff8R7AOuEnvnmslQ5AAAA").unwrap(), + params: Params::default(), + change_vout: 0, + receiver_inputs: vec![ + InputPair::new( + TxIn { + previous_output: OutPoint::from_str("7280a75c8ed4c73d9e7714a66c466ff95783f53e66a585eddd6a157f68b56d34:0").unwrap(), + ..Default::default() + }, Input { + witness_utxo: Some(TxOut { + value: Amount::from_sat(5_000_000_000), + script_pubkey: ScriptBuf::from_hex("0014916d91f1d3b77cb510331a37a0858e3356b69091").unwrap(), + }), + ..Default::default() + }, None) + .unwrap()], + }; + assert_eq!( + p2wpkh_proposal.additional_input_weight().expect("should calculate input weight"), + Weight::from_wu(272) + ); + + // Input weight for a single P2TR (taproot) receiver input + let p2tr_proposal = WantsFeeRange { + original_psbt: Psbt::from_str("cHNidP8BAHECAAAAAU/CHxd1oi9Lq1xOD2GnHe0hsQdGJ2mkpYkmeasTj+w1AAAAAAD9////Am3KBSoBAAAAFgAUqJL/PDPnHeihhNhukTz8QEdZbZAQJwAAAAAAABYAFInyO0NQF7YR22Sm0YTPGm6yf19YAAAAAAABASsA8gUqAQAAACJRIGOPekNKFs9ASLj3FdlCLiou/jdPUegJGzlA111A80MAAQhCAUC3zX8eSeL8+bAo6xO0cpon83UsJdttiuwfMn/pBwub82rzMsoS6HZNXzg7hfcB3p1uj8JmqsBkZwm8k6fnU2peACICA+u+FjwmhEgWdjhEQbO49D0NG8iCYUoqhlfsj0LN7hiRGOcVI65UAACAAQAAgAAAAIABAAAAAAAAAAAA").unwrap(), + payjoin_psbt: Psbt::from_str("cHNidP8BAJoCAAAAAk/CHxd1oi9Lq1xOD2GnHe0hsQdGJ2mkpYkmeasTj+w1AAAAAAD9////Fz+ELsYp/55j6+Jl2unG9sGvpHTiSyzSORBvtu1GEB4AAAAAAP3///8CM8oFKgEAAAAWABSokv88M+cd6KGE2G6RPPxAR1ltkBAZBioBAAAAFgAU68J5imRcKy3g5JCT3bEoP9IXEn0AAAAAAAEBKwDyBSoBAAAAIlEgY496Q0oWz0BIuPcV2UIuKi7+N09R6AkbOUDXXUDzQwAAAQErAPIFKgEAAAAiUSCfbbX+FHJbzC71eEFLsMjDouMJbu8ogeR0eNoNxMM9CwEIQwFBeyOLUebV/YwpaLTpLIaTXaSiPS7Dn6o39X4nlUzQLfb6YyvCAsLA5GTxo+Zb0NUINZ8DaRyUWknOpU/Jzuwn2gEAAAA=").unwrap(), + params: Params::default(), + change_vout: 0, + receiver_inputs: vec![ + InputPair::new( + TxIn { + previous_output: OutPoint::from_str("1e1046edb66f1039d22c4be274a4afc1f6c6e9da65e2eb639eff29c62e843f17:0").unwrap(), + ..Default::default() + }, Input { + witness_utxo: Some(TxOut { + value: Amount::from_sat(5_000_000_000), + script_pubkey: ScriptBuf::from_hex("51209f6db5fe14725bcc2ef578414bb0c8c3a2e3096eef2881e47478da0dc4c33d0b").unwrap(), + }), + ..Default::default() + }, None) + .unwrap()], + }; + assert_eq!( + p2tr_proposal.additional_input_weight().expect("should calculate input weight"), + Weight::from_wu(230) + ); + } + + #[test] + fn test_prepare_psbt_excludes_keypaths() { + let original = original_from_test_vector(); + let mut processed_psbt = original.psbt.clone(); + + let secp = Secp256k1::new(); + let (_, pk) = secp.generate_keypair(&mut bitcoin::key::rand::thread_rng()); + let xpriv = Xpriv::new_master(Network::Bitcoin, &[]).expect("Could not generate new xpriv"); + let (x_only, _) = pk.x_only_public_key(); + + processed_psbt.xpub.insert( + Xpub::from_priv(&secp, &xpriv), + (Fingerprint::default(), DerivationPath::default()), + ); + + for input in &mut processed_psbt.inputs { + input.bip32_derivation.insert(pk, (Fingerprint::default(), DerivationPath::default())); + input.tap_key_origins.insert( + x_only, + ( + vec![TapLeafHash::from_script(&ScriptBuf::new(), LeafVersion::TapScript)], + (Fingerprint::default(), DerivationPath::default()), + ), + ); + input.tap_internal_key = Some(x_only); + } + + for output in &mut processed_psbt.outputs { + output.bip32_derivation.insert(pk, (Fingerprint::default(), DerivationPath::default())); + output.tap_key_origins.insert( + x_only, + ( + vec![TapLeafHash::from_script(&ScriptBuf::new(), LeafVersion::TapScript)], + (Fingerprint::default(), DerivationPath::default()), + ), + ); + output.tap_internal_key = Some(x_only); + } + + let psbt_context = WantsOutputs::new(original_from_test_vector(), vec![0]) + .commit_outputs() + .commit_inputs() + .apply_fee_to_psbt_context(None, None) + .expect("Contributed inputs should allow for valid fee contributions"); + let payjoin_proposal = + psbt_context.finalize_proposal(|_| Ok(processed_psbt.clone())).expect("Valid psbt"); + + assert!(payjoin_proposal.xpub.is_empty()); + + for input in &payjoin_proposal.inputs { + assert!(input.bip32_derivation.is_empty()); + assert!(input.tap_key_origins.is_empty()); + assert!(input.tap_internal_key.is_none()); + } + + for output in &payjoin_proposal.outputs { + assert!(output.bip32_derivation.is_empty()); + assert!(output.tap_key_origins.is_empty()); + assert!(output.tap_internal_key.is_none()); + } + } + + #[test] + fn unchecked_proposal_unlocks_after_checks() { + let proposal = original_from_test_vector(); + let payjoin = WantsOutputs::new(proposal, vec![0]).commit_outputs().commit_inputs(); + { + let mut payjoin = payjoin.clone(); + let psbt = payjoin.apply_fee(None, None); + assert!(psbt.is_ok(), "Payjoin should be a valid PSBT"); + } + { + let mut payjoin = payjoin.clone(); + let psbt = payjoin.apply_fee(None, Some(FeeRate::ZERO)); + assert!(psbt.is_ok(), "Payjoin should be a valid PSBT"); + } + } +} diff --git a/payjoin/src/core/receive/error.rs b/payjoin/src/core/receive/error.rs index 0034ddb5a..b711e0dfd 100644 --- a/payjoin/src/core/receive/error.rs +++ b/payjoin/src/core/receive/error.rs @@ -1,8 +1,11 @@ use std::{error, fmt}; +use bitcoin::hashes::sha256d::Hash; + use crate::error_codes::ErrorCode::{ self, NotEnoughMoney, OriginalPsbtRejected, Unavailable, VersionUnsupported, }; +use crate::ImplementationError; /// The top-level error type for the payjoin receiver #[derive(Debug)] @@ -399,6 +402,27 @@ impl From for InputContributionError { fn from(value: InternalInputContributionError) -> Self { InputContributionError(value) } } +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum FinalizeProposalError { + /// The ntxid of the original PSBT does not match the ntxid of the finalized PSBT. + NtxidMismatch(Hash, Hash), + /// The implementation of the `wallet_process_psbt` function returned an error. + Implementation(ImplementationError), +} + +impl std::fmt::Display for FinalizeProposalError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NtxidMismatch(expected, actual) => { + write!(f, "Ntxid mismatch: expected {expected}, got {actual}") + } + Self::Implementation(e) => write!(f, "Implementation error: {e}"), + } + } +} + +impl std::error::Error for FinalizeProposalError {} + #[cfg(test)] mod tests { use super::*; diff --git a/payjoin/src/core/receive/mod.rs b/payjoin/src/core/receive/mod.rs index 635ea1553..5e9198968 100644 --- a/payjoin/src/core/receive/mod.rs +++ b/payjoin/src/core/receive/mod.rs @@ -12,16 +12,15 @@ use std::collections::BTreeMap; use std::str::FromStr; -use bitcoin::hashes::sha256d::Hash; use bitcoin::{ psbt, AddressType, FeeRate, OutPoint, Psbt, Script, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Weight, }; -pub(crate) use error::InternalPayloadError; pub use error::{ Error, InputContributionError, JsonReply, OutputSubstitutionError, PayloadError, ReplyableError, SelectionError, }; +pub(crate) use error::{FinalizeProposalError, InternalPayloadError}; use optional_parameters::Params; use serde::{Deserialize, Serialize}; @@ -31,14 +30,13 @@ use crate::psbt::{ }; use crate::{ImplementationError, Version}; +pub(crate) mod common; mod error; pub(crate) mod optional_parameters; #[cfg(feature = "v1")] #[cfg_attr(docsrs, doc(cfg(feature = "v1")))] pub mod v1; -#[cfg(not(feature = "v1"))] -pub(crate) mod v1; #[cfg(feature = "v2")] #[cfg_attr(docsrs, doc(cfg(feature = "v2")))] @@ -242,9 +240,9 @@ pub(crate) fn parse_payload( } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub(crate) struct PsbtContext { +pub struct PsbtContext { original_psbt: Psbt, - pub(crate) payjoin_psbt: Psbt, + payjoin_psbt: Psbt, } impl PsbtContext { @@ -313,7 +311,7 @@ impl PsbtContext { /// Finalization consists of two steps: /// 1. Remove all sender signatures which were received with the original PSBT as these signatures are now invalid. /// 2. Sign and finalize the resulting PSBT using the passed `wallet_process_psbt` signing function. - pub(crate) fn finalize_proposal( + fn finalize_proposal( self, wallet_process_psbt: impl Fn(&Psbt) -> Result, ) -> Result { @@ -337,31 +335,10 @@ impl PsbtContext { } } -#[derive(Debug, PartialEq, Eq)] -pub(crate) enum FinalizeProposalError { - /// The ntxid of the original PSBT does not match the ntxid of the finalized PSBT. - NtxidMismatch(Hash, Hash), - /// The implementation of the `wallet_process_psbt` function returned an error. - Implementation(ImplementationError), -} - -impl std::fmt::Display for FinalizeProposalError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::NtxidMismatch(expected, actual) => { - write!(f, "Ntxid mismatch: expected {expected}, got {actual}") - } - Self::Implementation(e) => write!(f, "Implementation error: {e}"), - } - } -} - -impl std::error::Error for FinalizeProposalError {} - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct Original { - pub(crate) psbt: Psbt, - pub(crate) params: Params, + psbt: Psbt, + params: Params, } impl Original { @@ -480,7 +457,7 @@ impl Original { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use bitcoin::absolute::{LockTime, Time}; use bitcoin::hashes::Hash; use bitcoin::key::{PublicKey, WPubkeyHash}; @@ -489,7 +466,7 @@ mod tests { use bitcoin::{ witness, Amount, PubkeyHash, ScriptBuf, ScriptHash, Txid, WScriptHash, XOnlyPublicKey, }; - use payjoin_test_utils::{DUMMY20, DUMMY32}; + use payjoin_test_utils::{DUMMY20, DUMMY32, PARSED_ORIGINAL_PSBT, QUERY_PARAMS}; use super::*; use crate::psbt::InternalPsbtInputError::InvalidScriptPubKey; @@ -498,6 +475,13 @@ mod tests { // We should pub(crate) it and moved to a common place. const NON_WITNESS_DATA_WEIGHT: Weight = Weight::from_non_witness_data_size(32 + 4 + 4); + pub(crate) fn original_from_test_vector() -> Original { + let pairs = url::form_urlencoded::parse(QUERY_PARAMS.as_bytes()); + let params = Params::from_query_pairs(pairs, &[Version::One]) + .expect("Could not parse params from query pairs"); + Original { psbt: PARSED_ORIGINAL_PSBT.clone(), params } + } + #[test] fn input_pair_with_expected_weight() { let p2wsh_txout = TxOut { diff --git a/payjoin/src/core/receive/v1/exclusive/error.rs b/payjoin/src/core/receive/v1/error.rs similarity index 100% rename from payjoin/src/core/receive/v1/exclusive/error.rs rename to payjoin/src/core/receive/v1/error.rs diff --git a/payjoin/src/core/receive/v1/exclusive/mod.rs b/payjoin/src/core/receive/v1/exclusive/mod.rs deleted file mode 100644 index 0eab9d874..000000000 --- a/payjoin/src/core/receive/v1/exclusive/mod.rs +++ /dev/null @@ -1,143 +0,0 @@ -mod error; -pub(crate) use error::InternalRequestError; -pub use error::RequestError; - -use super::*; -use crate::uri::PjParam; -use crate::{IntoUrl, PjParseError, Version}; - -const SUPPORTED_VERSIONS: &[Version] = &[Version::One]; - -pub trait Headers { - fn get_header(&self, key: &str) -> Option<&str>; -} - -pub fn build_v1_pj_uri<'a>( - address: &bitcoin::Address, - endpoint: impl IntoUrl, - output_substitution: OutputSubstitution, -) -> Result, PjParseError> { - let url = endpoint.into_url().map_err(crate::uri::error::InternalPjParseError::IntoUrl)?; - let pj_param = PjParam::V1(crate::uri::v1::PjParam::parse(url)?); - let extras = crate::uri::PayjoinExtras { pj_param, output_substitution }; - Ok(bitcoin_uri::Uri::with_extras(address.clone(), extras)) -} - -impl UncheckedProposal { - pub fn from_request( - body: &[u8], - query: &str, - headers: impl Headers, - ) -> Result { - let validated_body = validate_body(headers, body).map_err(ReplyableError::V1)?; - - let base64 = std::str::from_utf8(validated_body).map_err(InternalPayloadError::Utf8)?; - - let (psbt, params) = crate::receive::parse_payload(base64, query, SUPPORTED_VERSIONS) - .map_err(ReplyableError::Payload)?; - - Ok(UncheckedProposal { original: Original { psbt, params } }) - } -} - -/// Validate the request headers for a Payjoin request -/// -/// [`RequestError`] should only be produced here. -fn validate_body(headers: impl Headers, body: &[u8]) -> Result<&[u8], RequestError> { - let content_type = headers - .get_header("content-type") - .ok_or(InternalRequestError::MissingHeader("Content-Type"))?; - if !content_type.starts_with("text/plain") { - return Err(InternalRequestError::InvalidContentType(content_type.to_owned()).into()); - } - - let content_length = headers - .get_header("content-length") - .ok_or(InternalRequestError::MissingHeader("Content-Length"))? - .parse::() - .map_err(InternalRequestError::InvalidContentLength)?; - if body.len() != content_length { - return Err(InternalRequestError::ContentLengthMismatch { - expected: content_length, - actual: body.len(), - } - .into()); - } - - Ok(body) -} - -#[cfg(test)] -mod tests { - use bitcoin::{Address, AddressType}; - use payjoin_test_utils::{ORIGINAL_PSBT, QUERY_PARAMS}; - - use super::*; - - #[derive(Debug, Clone)] - struct MockHeaders { - length: String, - } - - impl MockHeaders { - fn new(length: u64) -> MockHeaders { MockHeaders { length: length.to_string() } } - } - - impl Headers for MockHeaders { - fn get_header(&self, key: &str) -> Option<&str> { - match key { - "content-length" => Some(&self.length), - "content-type" => Some("text/plain"), - _ => None, - } - } - } - - #[test] - fn test_parse_body() { - let body = ORIGINAL_PSBT.as_bytes().to_vec(); - let headers = MockHeaders::new((body.len() + 1) as u64); - - let validated_request = validate_body(headers.clone(), body.as_slice()); - assert!(validated_request.is_err()); - - match validated_request { - Ok(_) => panic!("Expected error, got success"), - Err(error) => { - assert_eq!( - error.to_string(), - RequestError::from(InternalRequestError::ContentLengthMismatch { - expected: body.len() + 1, - actual: body.len(), - }) - .to_string() - ); - } - } - } - - #[test] - fn test_from_request() -> Result<(), Box> { - let body = ORIGINAL_PSBT.as_bytes(); - let headers = MockHeaders::new(body.len() as u64); - let validated_request = validate_body(headers.clone(), body); - assert!(validated_request.is_ok()); - - let proposal = UncheckedProposal::from_request(body, QUERY_PARAMS, headers)?; - - let witness_utxo = proposal.original.psbt.inputs[0] - .witness_utxo - .as_ref() - .expect("witness_utxo should be present"); - let address = - Address::from_script(&witness_utxo.script_pubkey, bitcoin::params::Params::MAINNET)?; - assert_eq!(address.address_type(), Some(AddressType::P2sh)); - - assert_eq!(proposal.original.params.v, Version::One); - assert_eq!( - proposal.original.params.additional_fee_contribution, - Some((Amount::from_sat(182), 0)) - ); - Ok(()) - } -} diff --git a/payjoin/src/core/receive/v1/mod.rs b/payjoin/src/core/receive/v1/mod.rs index 0b952cc1b..dda92792b 100644 --- a/payjoin/src/core/receive/v1/mod.rs +++ b/payjoin/src/core/receive/v1/mod.rs @@ -31,29 +31,49 @@ //! Note: Even fresh requests may be linkable via metadata (e.g. client IP, request timing), //! but request reuse makes correlation trivial for the relay. -use std::cmp::{max, min}; +mod error; +use bitcoin::OutPoint; +pub(crate) use error::InternalRequestError; +pub use error::RequestError; -use bitcoin::psbt::Psbt; -use bitcoin::secp256k1::rand::seq::SliceRandom; -use bitcoin::secp256k1::rand::{self, Rng}; -use bitcoin::{Amount, FeeRate, OutPoint, Script, TxIn, TxOut, Weight}; -use serde::{Deserialize, Serialize}; +use super::*; +pub use crate::receive::common::{WantsFeeRange, WantsInputs, WantsOutputs}; +use crate::uri::PjParam; +use crate::{IntoUrl, OutputSubstitution, PjParseError, Version}; -use super::error::{ - InputContributionError, InternalInputContributionError, InternalOutputSubstitutionError, - InternalSelectionError, -}; -use super::optional_parameters::Params; -use super::{InputPair, OutputSubstitutionError, ReplyableError, SelectionError}; -use crate::output_substitution::OutputSubstitution; -use crate::psbt::PsbtExt; -use crate::receive::{InternalPayloadError, Original, PsbtContext}; -use crate::ImplementationError; +const SUPPORTED_VERSIONS: &[Version] = &[Version::One]; -#[cfg(feature = "v1")] -mod exclusive; -#[cfg(feature = "v1")] -pub use exclusive::*; +pub trait Headers { + fn get_header(&self, key: &str) -> Option<&str>; +} + +pub fn build_v1_pj_uri<'a>( + address: &bitcoin::Address, + endpoint: impl IntoUrl, + output_substitution: OutputSubstitution, +) -> Result, PjParseError> { + let url = endpoint.into_url().map_err(crate::uri::error::InternalPjParseError::IntoUrl)?; + let pj_param = PjParam::V1(crate::uri::v1::PjParam::parse(url)?); + let extras = crate::uri::PayjoinExtras { pj_param, output_substitution }; + Ok(bitcoin_uri::Uri::with_extras(address.clone(), extras)) +} + +impl UncheckedProposal { + pub fn from_request( + body: &[u8], + query: &str, + headers: impl Headers, + ) -> Result { + let validated_body = validate_body(headers, body).map_err(ReplyableError::V1)?; + + let base64 = std::str::from_utf8(validated_body).map_err(InternalPayloadError::Utf8)?; + + let (psbt, params) = crate::receive::parse_payload(base64, query, SUPPORTED_VERSIONS) + .map_err(ReplyableError::Payload)?; + + Ok(UncheckedProposal { original: Original { psbt, params } }) + } +} /// The original PSBT and the optional parameters received from the sender. /// @@ -72,7 +92,6 @@ pub use exclusive::*; /// /// If you are implementing an interactive payment receiver, then such checks are not necessary, and you /// can go ahead with calling [`Self::assume_interactive_receiver`] to move on to the next typestate. -#[cfg_attr(not(feature = "v1"), allow(dead_code))] #[derive(Debug, Clone)] pub struct UncheckedProposal { original: Original, @@ -90,7 +109,6 @@ impl UncheckedProposal { /// This can be used to further prevent probing attacks since the attacker would now need to probe the receiver /// with transactions which are both broadcastable and pay high fee. Unrelated to the probing attack scenario, /// this parameter also makes operating in a high fee environment easier for the receiver. - #[cfg_attr(not(feature = "v1"), allow(dead_code))] pub fn check_broadcast_suitability( self, min_fee_rate: Option, @@ -104,7 +122,6 @@ impl UncheckedProposal { /// /// Use this for interactive payment receivers, where there is no risk of a probing attack since the /// receiver needs to manually create payjoin URIs. - #[cfg_attr(not(feature = "v1"), allow(dead_code))] pub fn assume_interactive_receiver(self) -> MaybeInputsOwned { MaybeInputsOwned { original: self.original } } @@ -118,7 +135,6 @@ impl UncheckedProposal { /// /// Call [`Self::check_inputs_not_owned`] to proceed. #[derive(Debug, Clone)] -#[cfg_attr(not(feature = "v1"), allow(dead_code))] pub struct MaybeInputsOwned { pub(crate) original: Original, } @@ -129,7 +145,6 @@ impl MaybeInputsOwned { /// Use this for scheduling the broadcast of the original transaction as a fallback /// for the payjoin. Note that this function does not make any validation on whether /// the transaction is broadcastable; it simply extracts it. - #[cfg_attr(not(feature = "v1"), allow(dead_code))] pub fn extract_tx_to_schedule_broadcast(&self) -> bitcoin::Transaction { self.original.psbt.clone().extract_tx_unchecked_fee_rate() } @@ -137,7 +152,6 @@ impl MaybeInputsOwned { /// Check that the original PSBT has no receiver-owned inputs. /// /// An attacker can try to spend the receiver's own inputs. This check prevents that. - #[cfg_attr(not(feature = "v1"), allow(dead_code))] pub fn check_inputs_not_owned( self, is_owned: &mut impl FnMut(&Script) -> Result, @@ -151,7 +165,6 @@ impl MaybeInputsOwned { /// /// Call [`Self::check_no_inputs_seen_before`] to proceed. #[derive(Debug, Clone)] -#[cfg_attr(not(feature = "v1"), allow(dead_code))] pub struct MaybeInputsSeen { original: Original, } @@ -164,7 +177,6 @@ impl MaybeInputsSeen { /// and sending them back to the receiver. /// 2. Re-entrant payjoin, where the sender uses the payjoin PSBT of a previous payjoin as the /// original proposal PSBT of the current, new payjoin. - #[cfg_attr(not(feature = "v1"), allow(dead_code))] pub fn check_no_inputs_seen_before( self, is_known: &mut impl FnMut(&OutPoint) -> Result, @@ -181,7 +193,6 @@ impl MaybeInputsSeen { /// /// Call [`Self::identify_receiver_outputs`] to proceed. #[derive(Debug, Clone)] -#[cfg_attr(not(feature = "v1"), allow(dead_code))] pub struct OutputsUnknown { original: Original, } @@ -206,449 +217,38 @@ impl OutputsUnknown { // In case of there being multiple outputs paying to the receiver, we select the first one // as the `change_vout`, which we will default to when making single output changes in // future mutating typestates. - Ok(WantsOutputs::from_proposal(self.original, owned_vouts)) - } -} - -/// Typestate which the receiver may substitute or add outputs to. -/// -/// In addition to contributing new inputs to an existing PSBT, Payjoin allows the -/// receiver to substitute the original PSBT's outputs to potentially preserve privacy and batch transfers. -/// The receiver does not have to limit themselves to the address shared with the sender in the -/// original Payjoin URI, and can make substitutions of the existing outputs in the proposal. -/// -/// Call [`Self::commit_outputs`] to proceed. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct WantsOutputs { - original_psbt: Psbt, - payjoin_psbt: Psbt, - params: Params, - change_vout: usize, - owned_vouts: Vec, -} - -impl WantsOutputs { - /// Returns whether the receiver is allowed to substitute original outputs or not. - pub fn output_substitution(&self) -> OutputSubstitution { self.params.output_substitution } - - /// Substitute the receiver output script with the provided script. - pub fn substitute_receiver_script( - self, - output_script: &Script, - ) -> Result { - let output_value = self.original_psbt.unsigned_tx.output[self.change_vout].value; - let outputs = [TxOut { value: output_value, script_pubkey: output_script.into() }]; - self.replace_receiver_outputs(outputs, output_script) - } - - /// Replaces **all** receiver outputs with the one or more provided `replacement_outputs`, and - /// sets up the passed `drain_script` as the receiver-owned output which might have its value - /// adjusted based on the modifications the receiver makes in the subsequent typestates. - /// - /// The sender's outputs are not touched. Existing receiver outputs will be replaced with the - /// outputs in the `replacement_outputs` argument. The number of replacement outputs should - /// match or exceed the number of receiver outputs in the original proposal PSBT. - /// - /// The drain script is the receiver script which will have its value adjusted based on the - /// modifications the receiver makes on the transaction in the subsequent typestates. For - /// example, if the receiver adds their own input, then the drain script output will have its - /// value increased by the same amount. Or if an output needs to have its value reduced to - /// account for fees, the value of the output for this script will be reduced. - pub fn replace_receiver_outputs( - self, - replacement_outputs: impl IntoIterator, - drain_script: &Script, - ) -> Result { - let mut payjoin_psbt = self.original_psbt.clone(); - let mut outputs = vec![]; - let mut replacement_outputs: Vec = replacement_outputs.into_iter().collect(); - let mut rng = rand::thread_rng(); - // Substitute the existing receiver outputs, keeping the sender/receiver output ordering - for (i, original_output) in self.original_psbt.unsigned_tx.output.iter().enumerate() { - if self.owned_vouts.contains(&i) { - // Receiver output: substitute in-place a provided replacement output - if replacement_outputs.is_empty() { - return Err(InternalOutputSubstitutionError::NotEnoughOutputs.into()); - } - match replacement_outputs - .iter() - .position(|txo| txo.script_pubkey == original_output.script_pubkey) - { - // Select an output with the same address if one was provided - Some(pos) => { - let txo = replacement_outputs.swap_remove(pos); - if self.output_substitution() == OutputSubstitution::Disabled - && txo.value < original_output.value - { - return Err( - InternalOutputSubstitutionError::DecreasedValueWhenDisabled.into(), - ); - } - outputs.push(txo); - } - // Otherwise randomly select one of the replacement outputs - None => { - if self.output_substitution() == OutputSubstitution::Disabled { - return Err( - InternalOutputSubstitutionError::ScriptPubKeyChangedWhenDisabled - .into(), - ); - } - let index = rng.gen_range(0..replacement_outputs.len()); - let txo = replacement_outputs.swap_remove(index); - outputs.push(txo); - } - } - } else { - // Sender output: leave it as is - outputs.push(original_output.clone()); - } - } - // Insert all remaining outputs at random indices for privacy - interleave_shuffle(&mut outputs, &mut replacement_outputs, &mut rng); - // Identify the receiver output that will be used for change and fees - let change_vout = outputs.iter().position(|txo| txo.script_pubkey == *drain_script); - // Update the payjoin PSBT outputs - payjoin_psbt.outputs = vec![Default::default(); outputs.len()]; - payjoin_psbt.unsigned_tx.output = outputs; - Ok(Self { - original_psbt: self.original_psbt, - payjoin_psbt, - params: self.params, - change_vout: change_vout.ok_or(InternalOutputSubstitutionError::InvalidDrainScript)?, - owned_vouts: self.owned_vouts, - }) - } - - /// Commits the outputs as final, and moves on to the next typestate. - /// - /// Outputs cannot be modified after this function is called. - pub fn commit_outputs(self) -> WantsInputs { - WantsInputs { - original_psbt: self.original_psbt, - payjoin_psbt: self.payjoin_psbt, - params: self.params, - change_vout: self.change_vout, - receiver_inputs: vec![], - } - } - - pub(crate) fn from_proposal(proposal: Original, owned_vouts: Vec) -> Self { - Self { - original_psbt: proposal.psbt.clone(), - payjoin_psbt: proposal.psbt, - params: proposal.params, - change_vout: owned_vouts[0], - owned_vouts, - } - } -} - -/// Shuffles `new` vector, then interleaves its elements with those from `original`, -/// maintaining the relative order in `original` but randomly inserting elements from `new`. -/// -/// The combined result replaces the contents of `original`. -fn interleave_shuffle(original: &mut Vec, new: &mut [T], rng: &mut R) { - // Shuffle the substitute_outputs - new.shuffle(rng); - // Create a new vector to store the combined result - let mut combined = Vec::with_capacity(original.len() + new.len()); - // Initialize indices - let mut original_index = 0; - let mut new_index = 0; - // Interleave elements - while original_index < original.len() || new_index < new.len() { - if original_index < original.len() && (new_index >= new.len() || rng.gen_bool(0.5)) { - combined.push(original[original_index].clone()); - original_index += 1; - } else { - combined.push(new[new_index].clone()); - new_index += 1; - } + Ok(WantsOutputs::new(self.original, owned_vouts)) } - *original = combined; } -/// Typestate for a checked proposal which the receiver may contribute inputs to. +/// Validate the request headers for a Payjoin request /// -/// Call [`Self::commit_inputs`] to proceed. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct WantsInputs { - original_psbt: Psbt, - payjoin_psbt: Psbt, - params: Params, - change_vout: usize, - receiver_inputs: Vec, -} - -impl WantsInputs { - /// Selects and returns an input from `candidate_inputs` which will preserve the receiver's privacy by - /// avoiding the Unnecessary Input Heuristic 2 (UIH2) outlined in [Unnecessary Input - /// Heuristics and PayJoin Transactions by Ghesmati et al. (2022)](https://eprint.iacr.org/2022/589). - /// - /// Privacy preservation is only supported for 2-output transactions. If the PSBT has more than - /// 2 outputs or if none of the candidates are suitable for avoiding UIH2, this function - /// defaults to the first candidate in `candidate_inputs` list. - pub fn try_preserving_privacy( - &self, - candidate_inputs: impl IntoIterator, - ) -> Result { - let mut candidate_inputs = candidate_inputs.into_iter().peekable(); - - self.avoid_uih(&mut candidate_inputs) - .or_else(|_| self.select_first_candidate(&mut candidate_inputs)) - } - - /// Returns the candidate input which avoids the UIH2 defined in [Unnecessary Input - /// Heuristics and PayJoin Transactions by Ghesmati et al. (2022)](https://eprint.iacr.org/2022/589). - /// - /// Based on the paper, we are looking for the candidate input which, when added to the - /// transaction with 2 existing outputs, results in the minimum input amount to be lower than the minimum - /// output amount. Note that when calculating the minimum output amount, we consider the - /// post-contribution amounts, and expect the output which pays to the receiver to have its - /// value increased by the amount of the candidate input. - /// - /// Errors if the transaction does not have exactly 2 outputs. - fn avoid_uih( - &self, - candidate_inputs: impl IntoIterator, - ) -> Result { - if self.payjoin_psbt.outputs.len() != 2 { - return Err(InternalSelectionError::UnsupportedOutputLength.into()); - } - - let min_out_sats = self - .payjoin_psbt - .unsigned_tx - .output - .iter() - .map(|output| output.value) - .min() - .unwrap_or(Amount::MAX_MONEY); - - let min_in_sats = self - .payjoin_psbt - .input_pairs() - .filter_map(|input| input.previous_txout().ok().map(|txo| txo.value)) - .min() - .unwrap_or(Amount::MAX_MONEY); - - let prior_payment_sats = self.payjoin_psbt.unsigned_tx.output[self.change_vout].value; - - for input_pair in candidate_inputs { - let candidate_sats = input_pair.previous_txout().value; - let candidate_min_out = min(min_out_sats, prior_payment_sats + candidate_sats); - let candidate_min_in = min(min_in_sats, candidate_sats); - - if candidate_min_in > candidate_min_out { - // The candidate avoids UIH2 but conforms to UIH1: Optimal change heuristic. - // It implies the smallest output is the sender's change address. - return Ok(input_pair); - } - } - - // No suitable privacy preserving selection found - Err(InternalSelectionError::NotFound.into()) - } - - /// Returns the first candidate input in the provided list or errors if the list is empty. - fn select_first_candidate( - &self, - candidate_inputs: impl IntoIterator, - ) -> Result { - candidate_inputs.into_iter().next().ok_or(InternalSelectionError::Empty.into()) - } - - /// Contributes the provided list of inputs to the transaction at random indices. If the total input - /// amount exceeds the total output amount after the contribution, adds all excess amount to - /// the receiver change output. - pub fn contribute_inputs( - self, - inputs: impl IntoIterator, - ) -> Result { - let mut payjoin_psbt = self.payjoin_psbt.clone(); - // The payjoin proposal must not introduce mixed input sequence numbers - let original_sequence = self - .original_psbt - .unsigned_tx - .input - .first() - .map(|input| input.sequence) - .unwrap_or_default(); - - let inputs = inputs.into_iter().collect::>(); - - // Insert contributions at random indices for privacy - let mut rng = rand::thread_rng(); - let mut receiver_input_amount = Amount::ZERO; - for input_pair in inputs.clone() { - receiver_input_amount += input_pair.previous_txout().value; - let index = rng.gen_range(0..=self.payjoin_psbt.unsigned_tx.input.len()); - payjoin_psbt.inputs.insert(index, input_pair.psbtin); - payjoin_psbt - .unsigned_tx - .input - .insert(index, TxIn { sequence: original_sequence, ..input_pair.txin }); - } - - // Add the receiver change amount to the receiver change output, if applicable - let receiver_min_input_amount = self.receiver_min_input_amount(); - if receiver_input_amount >= receiver_min_input_amount { - let change_amount = receiver_input_amount - receiver_min_input_amount; - payjoin_psbt.unsigned_tx.output[self.change_vout].value += change_amount; - } else { - return Err(InternalInputContributionError::ValueTooLow.into()); - } - - let mut receiver_inputs = self.receiver_inputs; - receiver_inputs.extend(inputs); - - Ok(WantsInputs { - original_psbt: self.original_psbt, - payjoin_psbt, - params: self.params, - change_vout: self.change_vout, - receiver_inputs, - }) - } - - // Compute the minimum amount that the receiver must contribute to the transaction as input. - fn receiver_min_input_amount(&self) -> Amount { - let output_amount = self - .payjoin_psbt - .unsigned_tx - .output - .iter() - .fold(Amount::ZERO, |acc, output| acc + output.value); - let original_output_amount = self - .original_psbt - .unsigned_tx - .output - .iter() - .fold(Amount::ZERO, |acc, output| acc + output.value); - output_amount.checked_sub(original_output_amount).unwrap_or(Amount::ZERO) - } - - /// Commits the inputs as final, and moves on to the next typestate. - /// - /// Inputs cannot be modified after this function is called. - pub fn commit_inputs(self) -> WantsFeeRange { - WantsFeeRange { - original_psbt: self.original_psbt, - payjoin_psbt: self.payjoin_psbt, - params: self.params, - change_vout: self.change_vout, - receiver_inputs: self.receiver_inputs, +/// [`RequestError`] should only be produced here. +fn validate_body(headers: impl Headers, body: &[u8]) -> Result<&[u8], RequestError> { + let content_type = headers + .get_header("content-type") + .ok_or(InternalRequestError::MissingHeader("Content-Type"))?; + if !content_type.starts_with("text/plain") { + return Err(InternalRequestError::InvalidContentType(content_type.to_owned()).into()); + } + + let content_length = headers + .get_header("content-length") + .ok_or(InternalRequestError::MissingHeader("Content-Length"))? + .parse::() + .map_err(InternalRequestError::InvalidContentLength)?; + if body.len() != content_length { + return Err(InternalRequestError::ContentLengthMismatch { + expected: content_length, + actual: body.len(), } + .into()); } -} -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct WantsFeeRange { - original_psbt: Psbt, - payjoin_psbt: Psbt, - params: Params, - change_vout: usize, - receiver_inputs: Vec, + Ok(body) } -impl WantsFeeRange { - fn apply_fee( - &mut self, - min_fee_rate: Option, - max_effective_fee_rate: Option, - ) -> Result<&Psbt, InternalPayloadError> { - let min_fee_rate = min_fee_rate.unwrap_or(FeeRate::BROADCAST_MIN); - log::trace!("min_fee_rate: {min_fee_rate:?}"); - log::trace!("params.min_fee_rate: {:?}", self.params.min_fee_rate); - let min_fee_rate = max(min_fee_rate, self.params.min_fee_rate); - log::debug!("min_fee_rate: {min_fee_rate:?}"); - - let max_fee_rate = max_effective_fee_rate.unwrap_or(FeeRate::BROADCAST_MIN); - - // If the sender specified a fee contribution, the receiver is allowed to decrease the - // sender's fee output to pay for additional input fees. Any fees in excess of - // `max_additional_fee_contribution` must be covered by the receiver. - let input_contribution_weight = self.additional_input_weight()?; - let additional_fee = input_contribution_weight * min_fee_rate; - log::trace!("additional_fee: {additional_fee}"); - let mut receiver_additional_fee = additional_fee; - if additional_fee >= Amount::ONE_SAT { - log::trace!( - "self.params.additional_fee_contribution: {:?}", - self.params.additional_fee_contribution - ); - if let Some((max_additional_fee_contribution, additional_fee_output_index)) = - self.params.additional_fee_contribution - { - // Find the sender's specified output in the original psbt. - // This step is necessary because the sender output may have shifted if new - // receiver outputs were added to the payjoin psbt. - let sender_fee_output = - &self.original_psbt.unsigned_tx.output[additional_fee_output_index]; - // Find the index of that output in the payjoin psbt - let sender_fee_vout = self - .payjoin_psbt - .unsigned_tx - .output - .iter() - .position(|txo| txo.script_pubkey == sender_fee_output.script_pubkey) - .expect("Sender output is missing from payjoin PSBT"); - // Determine the additional amount that the sender will pay in fees - let sender_additional_fee = min(max_additional_fee_contribution, additional_fee); - log::trace!("sender_additional_fee: {sender_additional_fee}"); - // Remove additional miner fee from the sender's specified output - self.payjoin_psbt.unsigned_tx.output[sender_fee_vout].value -= - sender_additional_fee; - receiver_additional_fee -= sender_additional_fee; - } - } - - // The sender's fee contribution can only be used to pay for additional input weight, so - // any additional outputs must be paid for by the receiver. - let output_contribution_weight = self.additional_output_weight(); - receiver_additional_fee += output_contribution_weight * min_fee_rate; - log::trace!("receiver_additional_fee: {receiver_additional_fee}"); - // Ensure that the receiver does not pay more in fees - // than they would by building a separate transaction at max_effective_fee_rate instead. - let max_fee = (input_contribution_weight + output_contribution_weight) * max_fee_rate; - log::trace!("max_fee: {max_fee}"); - if receiver_additional_fee > max_fee { - let proposed_fee_rate = - receiver_additional_fee / (input_contribution_weight + output_contribution_weight); - return Err(InternalPayloadError::FeeTooHigh(proposed_fee_rate, max_fee_rate)); - } - if receiver_additional_fee >= Amount::ONE_SAT { - // Remove additional miner fee from the receiver's specified output - self.payjoin_psbt.unsigned_tx.output[self.change_vout].value -= receiver_additional_fee; - } - Ok(&self.payjoin_psbt) - } - - /// Calculate the additional input weight contributed by the receiver. - fn additional_input_weight(&self) -> Result { - Ok(self.receiver_inputs.iter().map(|input_pair| input_pair.expected_weight).sum()) - } - - /// Calculate the additional output weight contributed by the receiver. - fn additional_output_weight(&self) -> Weight { - let payjoin_outputs_weight = self - .payjoin_psbt - .unsigned_tx - .output - .iter() - .fold(Weight::ZERO, |acc, txo| acc + txo.weight()); - let original_outputs_weight = self - .original_psbt - .unsigned_tx - .output - .iter() - .fold(Weight::ZERO, |acc, txo| acc + txo.weight()); - let output_contribution_weight = payjoin_outputs_weight - original_outputs_weight; - log::trace!("output_contribution_weight : {output_contribution_weight}"); - output_contribution_weight - } - +impl crate::receive::common::WantsFeeRange { /// Applies additional fee contribution now that the receiver has contributed inputs /// and may have added new outputs. /// @@ -670,15 +270,12 @@ impl WantsFeeRange { /// The minimum effective fee limit is the highest of the minimum limit set by the sender in /// the original proposal parameters and the limit passed in the `min_fee_rate` parameter. pub fn apply_fee_range( - mut self, + self, min_fee_rate: Option, max_effective_fee_rate: Option, ) -> Result { - let psbt = self.apply_fee(min_fee_rate, max_effective_fee_rate)?.clone(); - Ok(ProvisionalProposal { - psbt_context: PsbtContext { original_psbt: self.original_psbt, payjoin_psbt: psbt }, - params: self.params, - }) + let psbt_context = self.apply_fee_to_psbt_context(min_fee_rate, max_effective_fee_rate)?; + Ok(ProvisionalProposal { psbt_context }) } } @@ -689,8 +286,7 @@ impl WantsFeeRange { /// Call [`Self::finalize_proposal`] to return a finalized [`PayjoinProposal`]. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ProvisionalProposal { - pub(crate) psbt_context: PsbtContext, - params: Params, + psbt_context: PsbtContext, } impl ProvisionalProposal { @@ -730,44 +326,92 @@ impl PayjoinProposal { } #[cfg(test)] -pub(crate) mod test { +mod tests { use std::str::FromStr; use bitcoin::absolute::{LockTime, Time}; - use bitcoin::bip32::{DerivationPath, Fingerprint, Xpriv, Xpub}; - use bitcoin::hashes::Hash; - use bitcoin::psbt::Input; - use bitcoin::secp256k1::Secp256k1; - use bitcoin::taproot::LeafVersion; - use bitcoin::{ - Address, Amount, Network, OutPoint, PubkeyHash, ScriptBuf, Sequence, TapLeafHash, - Transaction, - }; - use payjoin_test_utils::{ - DUMMY20, PARSED_ORIGINAL_PSBT, QUERY_PARAMS, RECEIVER_INPUT_CONTRIBUTION, - }; - use rand::rngs::StdRng; - use rand::SeedableRng; + use bitcoin::{Address, Amount, Network, Transaction}; + use payjoin_test_utils::{ORIGINAL_PSBT, PARSED_ORIGINAL_PSBT, QUERY_PARAMS}; use super::*; use crate::receive::PayloadError; use crate::Version; - pub(crate) fn proposal_from_test_vector() -> Original { - let pairs = url::form_urlencoded::parse(QUERY_PARAMS.as_bytes()); - let params = Params::from_query_pairs(pairs, &[Version::One]) - .expect("Could not parse params from query pairs"); - Original { psbt: PARSED_ORIGINAL_PSBT.clone(), params } + #[derive(Debug, Clone)] + struct MockHeaders { + length: String, + } + + impl MockHeaders { + fn new(length: u64) -> MockHeaders { MockHeaders { length: length.to_string() } } } - pub(crate) fn unchecked_proposal_from_test_vector() -> UncheckedProposal { + impl Headers for MockHeaders { + fn get_header(&self, key: &str) -> Option<&str> { + match key { + "content-length" => Some(&self.length), + "content-type" => Some("text/plain"), + _ => None, + } + } + } + + #[test] + fn test_parse_body() { + let body = ORIGINAL_PSBT.as_bytes().to_vec(); + let headers = MockHeaders::new((body.len() + 1) as u64); + + let validated_request = validate_body(headers.clone(), body.as_slice()); + assert!(validated_request.is_err()); + + match validated_request { + Ok(_) => panic!("Expected error, got success"), + Err(error) => { + assert_eq!( + error.to_string(), + RequestError::from(InternalRequestError::ContentLengthMismatch { + expected: body.len() + 1, + actual: body.len(), + }) + .to_string() + ); + } + } + } + + #[test] + fn test_from_request() -> Result<(), Box> { + let body = ORIGINAL_PSBT.as_bytes(); + let headers = MockHeaders::new(body.len() as u64); + let validated_request = validate_body(headers.clone(), body); + assert!(validated_request.is_ok()); + + let proposal = UncheckedProposal::from_request(body, QUERY_PARAMS, headers)?; + + let witness_utxo = proposal.original.psbt.inputs[0] + .witness_utxo + .as_ref() + .expect("witness_utxo should be present"); + let address = + Address::from_script(&witness_utxo.script_pubkey, bitcoin::params::Params::MAINNET)?; + assert_eq!(address.address_type(), Some(AddressType::P2sh)); + + assert_eq!(proposal.original.params.v, Version::One); + assert_eq!( + proposal.original.params.additional_fee_contribution, + Some((Amount::from_sat(182), 0)) + ); + Ok(()) + } + + fn unchecked_proposal_from_test_vector() -> UncheckedProposal { let pairs = url::form_urlencoded::parse(QUERY_PARAMS.as_bytes()); let params = Params::from_query_pairs(pairs, &[Version::One]) .expect("Could not parse params from query pairs"); UncheckedProposal { original: Original { psbt: PARSED_ORIGINAL_PSBT.clone(), params } } } - pub(crate) fn maybe_inputs_owned_from_test_vector() -> MaybeInputsOwned { + fn maybe_inputs_owned_from_test_vector() -> MaybeInputsOwned { let pairs = url::form_urlencoded::parse(QUERY_PARAMS.as_bytes()); let params = Params::from_query_pairs(pairs, &[Version::One]) .expect("Could not parse params from query pairs"); @@ -864,410 +508,6 @@ pub(crate) mod test { assert_eq!(proposal_below_min_fee.to_string(), expected_err.to_string()); } - #[test] - fn unchecked_proposal_unlocks_after_checks() { - let proposal = unchecked_proposal_from_test_vector(); - assert_eq!(proposal.original.psbt_fee_rate().unwrap().to_sat_per_vb_floor(), 2); - let payjoin = wants_outputs_from_test_vector(proposal).commit_outputs().commit_inputs(); - - { - let mut payjoin = payjoin.clone(); - let psbt = payjoin.apply_fee(None, None); - assert!(psbt.is_ok(), "Payjoin should be a valid PSBT"); - } - { - let mut payjoin = payjoin.clone(); - let psbt = payjoin.apply_fee(None, Some(FeeRate::ZERO)); - assert!(psbt.is_ok(), "Payjoin should be a valid PSBT"); - } - } - - #[test] - fn empty_candidates_inputs() { - let proposal = unchecked_proposal_from_test_vector(); - let wants_inputs = proposal - .assume_interactive_receiver() - .check_inputs_not_owned(&mut |_| Ok(false)) - .expect("No inputs should be owned") - .check_no_inputs_seen_before(&mut |_| Ok(false)) - .expect("No inputs should be seen before") - .identify_receiver_outputs(&mut |script| { - let network = Network::Bitcoin; - let target_address = Address::from_str("3CZZi7aWFugaCdUCS15dgrUUViupmB8bVM") - .map_err(ImplementationError::new)? - .require_network(network) - .map_err(ImplementationError::new)?; - - let script_address = - Address::from_script(script, network).map_err(ImplementationError::new)?; - Ok(script_address == target_address) - }) - .expect("Receiver output should be identified") - .commit_outputs(); - let empty_candidate_inputs: Vec = vec![]; - let result = wants_inputs.try_preserving_privacy(empty_candidate_inputs); - assert_eq!( - result.unwrap_err(), - SelectionError::from(InternalSelectionError::Empty), - "try_preserving_privacy should fail with empty candidate inputs" - ); - } - - #[test] - fn sender_specifies_excessive_fee_rate() { - let mut proposal = unchecked_proposal_from_test_vector(); - assert_eq!(proposal.original.psbt_fee_rate().unwrap().to_sat_per_vb_floor(), 2); - // Specify excessive fee rate in sender params - proposal.original.params.min_fee_rate = FeeRate::from_sat_per_vb_unchecked(1000); - let proposal_psbt = Psbt::from_str(RECEIVER_INPUT_CONTRIBUTION).unwrap(); - let input = InputPair::new( - proposal_psbt.unsigned_tx.input[1].clone(), - proposal_psbt.inputs[1].clone(), - None, - ) - .unwrap(); - let mut payjoin = wants_outputs_from_test_vector(proposal) - .commit_outputs() - .contribute_inputs(vec![input]) - .expect("Failed to contribute inputs") - .commit_inputs(); - let additional_output = TxOut { - value: Amount::ZERO, - script_pubkey: payjoin.original_psbt.unsigned_tx.output[0].script_pubkey.clone(), - }; - payjoin.payjoin_psbt.unsigned_tx.output.push(additional_output); - let mut payjoin_clone = payjoin.clone(); - let psbt = payjoin.apply_fee(None, Some(FeeRate::from_sat_per_vb_unchecked(1000))); - assert!(psbt.is_ok(), "Payjoin should be a valid PSBT"); - let psbt = payjoin_clone.apply_fee(None, Some(FeeRate::from_sat_per_vb_unchecked(995))); - match psbt { - Err(InternalPayloadError::FeeTooHigh(proposed, max)) => { - assert_eq!(FeeRate::from_str("249630").unwrap(), proposed); - assert_eq!(FeeRate::from_sat_per_vb_unchecked(995), max); - } - _ => panic!( - "Payjoin exceeds receiver fee preference and should error or unexpected error type" - ), - } - } - - #[test] - fn additional_input_weight_matches_known_weight() { - // All expected input weights pulled from: - // https://bitcoin.stackexchange.com/questions/84004/how-do-virtual-size-stripped-size-and-raw-size-compare-between-legacy-address-f#84006 - // Input weight for a single P2PKH (legacy) receiver input - let p2pkh_proposal = WantsFeeRange { - original_psbt: Psbt::from_str("cHNidP8BAHECAAAAAb2qhegy47hqffxh/UH5Qjd/G3sBH6cW2QSXZ86nbY3nAAAAAAD9////AhXKBSoBAAAAFgAU4TiLFD14YbpddFVrZa3+Zmz96yQQJwAAAAAAABYAFB4zA2o+5MsNRT/j+0twLi5VbwO9AAAAAAABAIcCAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////wMBSgD/////AgDyBSoBAAAAGXapFGUxpU6cGldVpjUm9rV2B+jTlphDiKwAAAAAAAAAACZqJKohqe3i9hw/cdHe/T+pmd+jaVN1XGkGiXmZYrSL69g2l06M+QAAAAABB2pHMEQCIGsOxO/bBv20bd68sBnEU3cxHR8OxEcUroL3ENhhjtN3AiB+9yWuBGKXu41hcfO4KP7IyLLEYc6j8hGowmAlCPCMPAEhA6WNSN4CqJ9F+42YKPlIFN0wJw7qawWbdelGRMkAbBRnACICAsdIAjsfMLKgfL2J9rfIa8yKdO1BOpSGRIFbFMBdTsc9GE4roNNUAACAAQAAgAAAAIABAAAAAAAAAAAA").unwrap(), - payjoin_psbt: Psbt::from_str("cHNidP8BAJoCAAAAAtTRxwAtk38fRMP3ffdKkIi5r+Ss9AjaO8qEv+eQ/ho3AAAAAAD9////vaqF6DLjuGp9/GH9QflCN38bewEfpxbZBJdnzqdtjecAAAAAAP3///8CgckFKgEAAAAWABThOIsUPXhhul10VWtlrf5mbP3rJBAZBioBAAAAFgAUiDIby0wSbj1kv3MlvwoEKw3vNZUAAAAAAAEAhwIAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////AwFoAP////8CAPIFKgEAAAAZdqkUPXhu3I6D9R0wUpvTvvUm+VGNcNuIrAAAAAAAAAAAJmokqiGp7eL2HD9x0d79P6mZ36NpU3VcaQaJeZlitIvr2DaXToz5AAAAAAEBIgDyBSoBAAAAGXapFD14btyOg/UdMFKb0771JvlRjXDbiKwBB2pHMEQCIGzKy8QfhHoAY0+LZCpQ7ZOjyyXqaSBnr89hH3Eg/xsGAiB3n8hPRuXCX/iWtURfXoJNUFu3sLeQVFf1dDFCZPN0dAEhA8rTfrwcq6dEBSNOrUfNb8+dm7q77vCtfdOmWx0HfajRAAEAhwIAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////AwFKAP////8CAPIFKgEAAAAZdqkUZTGlTpwaV1WmNSb2tXYH6NOWmEOIrAAAAAAAAAAAJmokqiGp7eL2HD9x0d79P6mZ36NpU3VcaQaJeZlitIvr2DaXToz5AAAAAAAAAA==").unwrap(), - params: Params::default(), - change_vout: 0, - receiver_inputs: vec![ - InputPair::new( - TxIn{ - previous_output: OutPoint::from_str("371afe90e7bf84ca3bda08f4ace4afb988904af77df7c3441f7f932d00c7d1d4:0").unwrap(), - ..Default::default() - }, Input { - witness_utxo: Some(TxOut { - value: Amount::from_sat(5_000_000_000), - script_pubkey: ScriptBuf::from_hex("76a9143d786edc8e83f51d30529bd3bef526f9518d70db88ac").unwrap(), - }), - ..Default::default() - }, None) - .unwrap()], - }; - assert_eq!( - p2pkh_proposal.additional_input_weight().expect("should calculate input weight"), - Weight::from_wu(592) - ); - - // Input weight for a single nested P2WPKH (nested segwit) receiver input - let nested_p2wpkh_proposal = WantsFeeRange { - original_psbt: Psbt::from_str("cHNidP8BAHECAAAAAeOsT9cRWRz3te+bgmtweG1vDLkdSH4057NuoodDNPFWAAAAAAD9////AhAnAAAAAAAAFgAUtp3bPFM/YWThyxD5Cc9OR4mb8tdMygUqAQAAABYAFODlplDoE6EGlZvmqoUngBgsu8qCAAAAAAABAIUCAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////wMBZwD/////AgDyBSoBAAAAF6kU2JnIn4Mmcb5kuF3EYeFei8IB43qHAAAAAAAAAAAmaiSqIant4vYcP3HR3v0/qZnfo2lTdVxpBol5mWK0i+vYNpdOjPkAAAAAAQEgAPIFKgEAAAAXqRTYmcifgyZxvmS4XcRh4V6LwgHjeocBBxcWABSPGoPK1yl60X4Z9OfA7IQPUWCgVwEIawJHMEQCICZG3s2cbulPnLTvK4TwlKhsC+cem8tD2GjZZ3eMJD7FAiADh/xwv0ib8ksOrj1M27DYLiw7WFptxkMkE2YgiNMRVgEhAlDMm5DA8kU+QGiPxEWUyV1S8+XGzUOepUOck257ZOhkAAAiAgP+oMbeca66mt+UtXgHm6v/RIFEpxrwG7IvPDim5KWHpBgfVHrXVAAAgAEAAIAAAACAAQAAAAAAAAAA").unwrap(), - payjoin_psbt: Psbt::from_str("cHNidP8BAJoCAAAAAuXYOTUaVRiB8cPPhEXzcJ72/SgZOPEpPx5pkG0fNeGCAAAAAAD9////46xP1xFZHPe175uCa3B4bW8MuR1IfjTns26ih0M08VYAAAAAAP3///8CEBkGKgEAAAAWABQHuuu4H4fbQWV51IunoJLUtmMTfEzKBSoBAAAAFgAU4OWmUOgToQaVm+aqhSeAGCy7yoIAAAAAAAEBIADyBSoBAAAAF6kUQ4BssmVBS3r0s95c6dl1DQCHCR+HAQQWABQbDc333XiiOeEXroP523OoYNb1aAABAIUCAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////wMBZwD/////AgDyBSoBAAAAF6kU2JnIn4Mmcb5kuF3EYeFei8IB43qHAAAAAAAAAAAmaiSqIant4vYcP3HR3v0/qZnfo2lTdVxpBol5mWK0i+vYNpdOjPkAAAAAAQEgAPIFKgEAAAAXqRTYmcifgyZxvmS4XcRh4V6LwgHjeocBBxcWABSPGoPK1yl60X4Z9OfA7IQPUWCgVwEIawJHMEQCICZG3s2cbulPnLTvK4TwlKhsC+cem8tD2GjZZ3eMJD7FAiADh/xwv0ib8ksOrj1M27DYLiw7WFptxkMkE2YgiNMRVgEhAlDMm5DA8kU+QGiPxEWUyV1S8+XGzUOepUOck257ZOhkAAAA").unwrap(), - params: Params::default(), - change_vout: 0, - receiver_inputs: vec![ - InputPair::new( - TxIn { - previous_output: OutPoint::from_str("82e1351f6d90691e3f29f1381928fdf69e70f34584cfc3f18118551a3539d8e5:0").unwrap(), - ..Default::default() - }, - Input { - witness_utxo: Some(TxOut { - value: Amount::from_sat(5_000_000_000), - script_pubkey: ScriptBuf::from_hex("a91443806cb265414b7af4b3de5ce9d9750d0087091f87").unwrap(), - }), - redeem_script: Some(ScriptBuf::from_hex("00141b0dcdf7dd78a239e117ae83f9db73a860d6f568").unwrap()), - ..Default::default() - }, None) - .unwrap()], - }; - assert_eq!( - nested_p2wpkh_proposal - .additional_input_weight() - .expect("should calculate input weight"), - Weight::from_wu(364) - ); - - // Input weight for a single P2WPKH (native segwit) receiver input - let p2wpkh_proposal = WantsFeeRange { - original_psbt: Psbt::from_str("cHNidP8BAHECAAAAASom13OiXZIr3bKk+LtUndZJYqdHQQU8dMs1FZ93IctIAAAAAAD9////AmPKBSoBAAAAFgAU6H98YM9NE1laARQ/t9/90nFraf4QJwAAAAAAABYAFBPJFmYuJBsrIaBBp9ur98pMSKxhAAAAAAABAIQCAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////wMBWwD/////AgDyBSoBAAAAFgAUjTJXmC73n+URSNdfgbS6Oa6JyQYAAAAAAAAAACZqJKohqe3i9hw/cdHe/T+pmd+jaVN1XGkGiXmZYrSL69g2l06M+QAAAAABAR8A8gUqAQAAABYAFI0yV5gu95/lEUjXX4G0ujmuickGAQhrAkcwRAIgUqbHS0difIGTRwN56z2/EiqLQFWerfJspyjuwsGSCXcCIA3IRTu8FVgniU5E4gecAMeegVnlTbTVfFyusWhQ2kVVASEDChVRm26KidHNWLdCLBTq5jspGJr+AJyyMqmUkvPkwFsAIgIDeBqmRB3ESjFWIp+wUXn/adGZU3kqWGjdkcnKpk8bAyUY94v8N1QAAIABAACAAAAAgAEAAAAAAAAAAAA=").unwrap(), - payjoin_psbt: Psbt::from_str("cHNidP8BAJoCAAAAAiom13OiXZIr3bKk+LtUndZJYqdHQQU8dMs1FZ93IctIAAAAAAD9////NG21aH8Vat3thaVmPvWDV/lvRmymFHeePcfUjlyngHIAAAAAAP3///8CH8oFKgEAAAAWABTof3xgz00TWVoBFD+33/3ScWtp/hAZBioBAAAAFgAU1mbnqky3bMxfmm0OgFaQCAs5fsoAAAAAAAEAhAIAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////AwFbAP////8CAPIFKgEAAAAWABSNMleYLvef5RFI11+BtLo5ronJBgAAAAAAAAAAJmokqiGp7eL2HD9x0d79P6mZ36NpU3VcaQaJeZlitIvr2DaXToz5AAAAAAEBHwDyBSoBAAAAFgAUjTJXmC73n+URSNdfgbS6Oa6JyQYAAQCEAgAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP////8DAWcA/////wIA8gUqAQAAABYAFJFtkfHTt3y1EDMaN6CFjjNWtpCRAAAAAAAAAAAmaiSqIant4vYcP3HR3v0/qZnfo2lTdVxpBol5mWK0i+vYNpdOjPkAAAAAAQEfAPIFKgEAAAAWABSRbZHx07d8tRAzGjeghY4zVraQkQEIawJHMEQCIDTC49IB9AnItqd8zy5RDc05f2ApBAfJ5x4zYfj3bsD2AiAQvvSt5ipScHcUwdlYB9vFnEi68hmh55M5a5e+oWvxMAEhAqErVSVulFb97/r5KQryOS1Xgghff8R7AOuEnvnmslQ5AAAA").unwrap(), - params: Params::default(), - change_vout: 0, - receiver_inputs: vec![ - InputPair::new( - TxIn { - previous_output: OutPoint::from_str("7280a75c8ed4c73d9e7714a66c466ff95783f53e66a585eddd6a157f68b56d34:0").unwrap(), - ..Default::default() - }, Input { - witness_utxo: Some(TxOut { - value: Amount::from_sat(5_000_000_000), - script_pubkey: ScriptBuf::from_hex("0014916d91f1d3b77cb510331a37a0858e3356b69091").unwrap(), - }), - ..Default::default() - }, None) - .unwrap()], - }; - assert_eq!( - p2wpkh_proposal.additional_input_weight().expect("should calculate input weight"), - Weight::from_wu(272) - ); - - // Input weight for a single P2TR (taproot) receiver input - let p2tr_proposal = WantsFeeRange { - original_psbt: Psbt::from_str("cHNidP8BAHECAAAAAU/CHxd1oi9Lq1xOD2GnHe0hsQdGJ2mkpYkmeasTj+w1AAAAAAD9////Am3KBSoBAAAAFgAUqJL/PDPnHeihhNhukTz8QEdZbZAQJwAAAAAAABYAFInyO0NQF7YR22Sm0YTPGm6yf19YAAAAAAABASsA8gUqAQAAACJRIGOPekNKFs9ASLj3FdlCLiou/jdPUegJGzlA111A80MAAQhCAUC3zX8eSeL8+bAo6xO0cpon83UsJdttiuwfMn/pBwub82rzMsoS6HZNXzg7hfcB3p1uj8JmqsBkZwm8k6fnU2peACICA+u+FjwmhEgWdjhEQbO49D0NG8iCYUoqhlfsj0LN7hiRGOcVI65UAACAAQAAgAAAAIABAAAAAAAAAAAA").unwrap(), - payjoin_psbt: Psbt::from_str("cHNidP8BAJoCAAAAAk/CHxd1oi9Lq1xOD2GnHe0hsQdGJ2mkpYkmeasTj+w1AAAAAAD9////Fz+ELsYp/55j6+Jl2unG9sGvpHTiSyzSORBvtu1GEB4AAAAAAP3///8CM8oFKgEAAAAWABSokv88M+cd6KGE2G6RPPxAR1ltkBAZBioBAAAAFgAU68J5imRcKy3g5JCT3bEoP9IXEn0AAAAAAAEBKwDyBSoBAAAAIlEgY496Q0oWz0BIuPcV2UIuKi7+N09R6AkbOUDXXUDzQwAAAQErAPIFKgEAAAAiUSCfbbX+FHJbzC71eEFLsMjDouMJbu8ogeR0eNoNxMM9CwEIQwFBeyOLUebV/YwpaLTpLIaTXaSiPS7Dn6o39X4nlUzQLfb6YyvCAsLA5GTxo+Zb0NUINZ8DaRyUWknOpU/Jzuwn2gEAAAA=").unwrap(), - params: Params::default(), - change_vout: 0, - receiver_inputs: vec![ - InputPair::new( - TxIn { - previous_output: OutPoint::from_str("1e1046edb66f1039d22c4be274a4afc1f6c6e9da65e2eb639eff29c62e843f17:0").unwrap(), - ..Default::default() - }, Input { - witness_utxo: Some(TxOut { - value: Amount::from_sat(5_000_000_000), - script_pubkey: ScriptBuf::from_hex("51209f6db5fe14725bcc2ef578414bb0c8c3a2e3096eef2881e47478da0dc4c33d0b").unwrap(), - }), - ..Default::default() - }, None) - .unwrap()], - }; - assert_eq!( - p2tr_proposal.additional_input_weight().expect("should calculate input weight"), - Weight::from_wu(230) - ); - } - - #[test] - fn test_pjos_disabled() { - let mut proposal = unchecked_proposal_from_test_vector(); - proposal.original.params.output_substitution = OutputSubstitution::Disabled; - let wants_outputs = wants_outputs_from_test_vector(proposal); - let script_pubkey = &wants_outputs.original_psbt.unsigned_tx.output - [wants_outputs.change_vout] - .script_pubkey; - - let output_value = - wants_outputs.original_psbt.unsigned_tx.output[wants_outputs.change_vout].value; - let outputs = vec![TxOut { value: output_value, script_pubkey: script_pubkey.clone() }]; - let unchanged_amount = - wants_outputs.clone().replace_receiver_outputs(outputs, script_pubkey.as_script()); - assert!( - unchanged_amount.is_ok(), - "Not touching the receiver output amount is always allowed" - ); - assert_ne!(wants_outputs.payjoin_psbt, unchanged_amount.unwrap().payjoin_psbt); - - let output_value = - wants_outputs.original_psbt.unsigned_tx.output[wants_outputs.change_vout].value - + Amount::ONE_SAT; - let outputs = vec![TxOut { value: output_value, script_pubkey: script_pubkey.clone() }]; - let increased_amount = - wants_outputs.clone().replace_receiver_outputs(outputs, script_pubkey.as_script()); - assert!( - increased_amount.is_ok(), - "Increasing the receiver output amount is always allowed" - ); - assert_ne!(wants_outputs.payjoin_psbt, increased_amount.unwrap().payjoin_psbt); - - let output_value = - wants_outputs.original_psbt.unsigned_tx.output[wants_outputs.change_vout].value - - Amount::ONE_SAT; - let outputs = vec![TxOut { value: output_value, script_pubkey: script_pubkey.clone() }]; - let decreased_amount = - wants_outputs.clone().replace_receiver_outputs(outputs, script_pubkey.as_script()); - assert_eq!( - decreased_amount.unwrap_err(), - OutputSubstitutionError::from( - InternalOutputSubstitutionError::DecreasedValueWhenDisabled - ), - "Payjoin receiver amount has been decreased and should error" - ); - - let script = Script::new(); - let replace_receiver_script_pubkey = wants_outputs.substitute_receiver_script(script); - assert_eq!( - replace_receiver_script_pubkey.unwrap_err(), - OutputSubstitutionError::from( - InternalOutputSubstitutionError::ScriptPubKeyChangedWhenDisabled - ), - "Payjoin receiver script pubkey has been modified and should error" - ); - } - - #[test] - fn test_avoid_uih_one_output() { - let proposal = unchecked_proposal_from_test_vector(); - let proposal_psbt = Psbt::from_str(RECEIVER_INPUT_CONTRIBUTION).unwrap(); - let input = InputPair::new( - proposal_psbt.unsigned_tx.input[1].clone(), - proposal_psbt.inputs[1].clone(), - None, - ) - .unwrap(); - let input_iter = [input].into_iter(); - let mut payjoin = wants_outputs_from_test_vector(proposal) - .commit_outputs() - .contribute_inputs(input_iter.clone()) - .expect("Failed to contribute inputs"); - - payjoin.payjoin_psbt.outputs.pop(); - let avoid_uih = payjoin.avoid_uih(input_iter); - assert_eq!( - avoid_uih.unwrap_err(), - SelectionError::from(InternalSelectionError::UnsupportedOutputLength), - "Payjoin below minimum allowed outputs for avoid uih and should error" - ); - } - - #[test] - fn test_interleave_shuffle() { - let mut original1 = vec![1, 2, 3]; - let mut original2 = original1.clone(); - let mut original3 = original1.clone(); - let mut new1 = vec![4, 5, 6]; - let mut new2 = new1.clone(); - let mut new3 = new1.clone(); - let mut rng1 = StdRng::seed_from_u64(123); - let mut rng2 = StdRng::seed_from_u64(234); - let mut rng3 = StdRng::seed_from_u64(345); - // Operate on the same data multiple times with different RNG seeds. - interleave_shuffle(&mut original1, &mut new1, &mut rng1); - interleave_shuffle(&mut original2, &mut new2, &mut rng2); - interleave_shuffle(&mut original3, &mut new3, &mut rng3); - // The result should be different for each seed - // and the relative ordering from `original` always preserved/ - assert_eq!(original1, vec![1, 6, 2, 5, 4, 3]); - assert_eq!(original2, vec![1, 5, 4, 2, 6, 3]); - assert_eq!(original3, vec![4, 5, 1, 2, 6, 3]); - } - - /// Add keypath data to psbt to be prepared and verify it is excluded from the final PSBT - /// See: - #[test] - fn test_prepare_psbt_excludes_keypaths() { - let proposal = unchecked_proposal_from_test_vector(); - let mut processed_psbt = proposal.original.psbt.clone(); - - let secp = Secp256k1::new(); - let (_, pk) = secp.generate_keypair(&mut bitcoin::key::rand::thread_rng()); - let xpriv = Xpriv::new_master(Network::Bitcoin, &[]).expect("Could not generate new xpriv"); - let (x_only, _) = pk.x_only_public_key(); - - processed_psbt.xpub.insert( - Xpub::from_priv(&secp, &xpriv), - (Fingerprint::default(), DerivationPath::default()), - ); - - for input in &mut processed_psbt.inputs { - input.bip32_derivation.insert(pk, (Fingerprint::default(), DerivationPath::default())); - input.tap_key_origins.insert( - x_only, - ( - vec![TapLeafHash::from_script(&ScriptBuf::new(), LeafVersion::TapScript)], - (Fingerprint::default(), DerivationPath::default()), - ), - ); - input.tap_internal_key = Some(x_only); - } - - for output in &mut processed_psbt.outputs { - output.bip32_derivation.insert(pk, (Fingerprint::default(), DerivationPath::default())); - output.tap_key_origins.insert( - x_only, - ( - vec![TapLeafHash::from_script(&ScriptBuf::new(), LeafVersion::TapScript)], - (Fingerprint::default(), DerivationPath::default()), - ), - ); - output.tap_internal_key = Some(x_only); - } - - let provisional = provisional_proposal_from_test_vector(proposal); - let payjoin_proposal = - provisional.finalize_proposal(|_| Ok(processed_psbt.clone())).expect("Valid psbt"); - - assert!(payjoin_proposal.payjoin_psbt.xpub.is_empty()); - - for input in &payjoin_proposal.payjoin_psbt.inputs { - assert!(input.bip32_derivation.is_empty()); - assert!(input.tap_key_origins.is_empty()); - assert!(input.tap_internal_key.is_none()); - } - - for output in &payjoin_proposal.payjoin_psbt.outputs { - assert!(output.bip32_derivation.is_empty()); - assert!(output.tap_key_origins.is_empty()); - assert!(output.tap_internal_key.is_none()); - } - } - - #[test] - fn test_multiple_contribute_inputs() { - let proposal = unchecked_proposal_from_test_vector(); - let wants_inputs = wants_outputs_from_test_vector(proposal).commit_outputs(); - let txout = TxOut { - value: Amount::from_sat(123), - script_pubkey: ScriptBuf::new_p2pkh(&PubkeyHash::from_byte_array(DUMMY20)), - }; - let tx = Transaction { - version: bitcoin::transaction::Version::TWO, - lock_time: LockTime::Seconds(Time::MIN), - input: vec![], - output: vec![txout.clone()], - }; - let ot1 = OutPoint { txid: tx.compute_txid(), vout: 0 }; - let ot2 = OutPoint { txid: tx.compute_txid(), vout: 1 }; - - let input_pair_1 = InputPair::new( - TxIn { previous_output: ot1, sequence: Sequence::MAX, ..Default::default() }, - Input { witness_utxo: Some(txout.clone()), ..Default::default() }, - None, - ) - .unwrap(); - let input_pair_2 = InputPair::new( - TxIn { previous_output: ot2, sequence: Sequence::MAX, ..Default::default() }, - Input { witness_utxo: Some(txout), ..Default::default() }, - None, - ) - .unwrap(); - - let wants_inputs = wants_inputs.contribute_inputs(vec![input_pair_1.clone()]).unwrap(); - assert_eq!(wants_inputs.receiver_inputs.len(), 1); - assert_eq!(wants_inputs.receiver_inputs[0], input_pair_1); - // Contribute the same input again, and a new input. - // TODO: if we ever decide to fix contribute duplicate inputs, we need to update this test. - let wants_inputs = wants_inputs - .contribute_inputs(vec![input_pair_2.clone(), input_pair_1.clone()]) - .unwrap(); - assert_eq!(wants_inputs.receiver_inputs.len(), 3); - assert_eq!(wants_inputs.receiver_inputs[0], input_pair_1); - assert_eq!(wants_inputs.receiver_inputs[1], input_pair_2); - assert_eq!(wants_inputs.receiver_inputs[2], input_pair_1); - } - #[test] fn test_finalize_proposal_invalid_payjoin_proposal() { let proposal = unchecked_proposal_from_test_vector(); diff --git a/payjoin/src/core/receive/v2/mod.rs b/payjoin/src/core/receive/v2/mod.rs index 79c5932f7..71ed3ac90 100644 --- a/payjoin/src/core/receive/v2/mod.rs +++ b/payjoin/src/core/receive/v2/mod.rs @@ -40,7 +40,8 @@ use url::Url; use super::error::{Error, InputContributionError}; use super::{ - v1, InternalPayloadError, JsonReply, OutputSubstitutionError, ReplyableError, SelectionError, + common, InternalPayloadError, JsonReply, OutputSubstitutionError, ReplyableError, + SelectionError, }; use crate::hpke::{decrypt_message_a, encrypt_message_b, HpkeKeyPair, HpkePublicKey}; use crate::ohttp::{ @@ -161,8 +162,8 @@ impl ReceiveSession { ( ReceiveSession::WantsFeeRange(state), - SessionEvent::ProvisionalProposal(provisional_proposal), - ) => Ok(state.apply_provisional_proposal(provisional_proposal)), + SessionEvent::ProvisionalProposal(psbt_context), + ) => Ok(state.apply_provisional_proposal(psbt_context)), ( ReceiveSession::ProvisionalProposal(state), @@ -705,28 +706,23 @@ impl Receiver { } }, }; - let wants_outputs = v1::WantsOutputs::from_proposal(self.state.original, owned_vouts); + let inner = common::WantsOutputs::new(self.state.original, owned_vouts); MaybeFatalTransition::success( - SessionEvent::WantsOutputs(wants_outputs.clone()), - Receiver { - state: WantsOutputs { - v1: wants_outputs, - session_context: self.state.session_context, - }, - }, + SessionEvent::WantsOutputs(inner.clone()), + Receiver { state: WantsOutputs { inner, session_context: self.state.session_context } }, ) } - pub(crate) fn apply_wants_outputs(self, v1: v1::WantsOutputs) -> ReceiveSession { + pub(crate) fn apply_wants_outputs(self, inner: common::WantsOutputs) -> ReceiveSession { let new_state = - Receiver { state: WantsOutputs { v1, session_context: self.state.session_context } }; + Receiver { state: WantsOutputs { inner, session_context: self.state.session_context } }; ReceiveSession::WantsOutputs(new_state) } } #[derive(Debug, Clone, PartialEq)] pub struct WantsOutputs { - v1: v1::WantsOutputs, + inner: common::WantsOutputs, session_context: SessionContext, } @@ -742,17 +738,15 @@ impl State for WantsOutputs {} /// Call [`Receiver::commit_outputs`] to proceed. impl Receiver { /// Whether the receiver is allowed to substitute original outputs or not. - pub fn output_substitution(&self) -> OutputSubstitution { self.v1.output_substitution() } + pub fn output_substitution(&self) -> OutputSubstitution { self.inner.output_substitution() } /// Substitute the receiver output script with the provided script. pub fn substitute_receiver_script( self, output_script: &Script, ) -> Result { - let inner = self.state.v1.substitute_receiver_script(output_script)?; - Ok(Receiver { - state: WantsOutputs { v1: inner, session_context: self.state.session_context }, - }) + let inner = self.state.inner.substitute_receiver_script(output_script)?; + Ok(Receiver { state: WantsOutputs { inner, session_context: self.state.session_context } }) } /// Replaces **all** receiver outputs with the one or more provided `replacement_outputs`, and @@ -773,35 +767,31 @@ impl Receiver { replacement_outputs: impl IntoIterator, drain_script: &Script, ) -> Result { - let inner = self.state.v1.replace_receiver_outputs(replacement_outputs, drain_script)?; - Ok(Receiver { - state: WantsOutputs { v1: inner, session_context: self.state.session_context }, - }) + let inner = self.state.inner.replace_receiver_outputs(replacement_outputs, drain_script)?; + Ok(Receiver { state: WantsOutputs { inner, session_context: self.state.session_context } }) } /// Commits the outputs as final, and moves on to the next typestate. /// /// Outputs cannot be modified after this function is called. pub fn commit_outputs(self) -> NextStateTransition> { - let inner = self.state.v1.clone().commit_outputs(); + let inner = self.state.inner.clone().commit_outputs(); NextStateTransition::success( SessionEvent::WantsInputs(inner.clone()), - Receiver { - state: WantsInputs { v1: inner, session_context: self.state.session_context }, - }, + Receiver { state: WantsInputs { inner, session_context: self.state.session_context } }, ) } - pub(crate) fn apply_wants_inputs(self, v1: v1::WantsInputs) -> ReceiveSession { + pub(crate) fn apply_wants_inputs(self, inner: common::WantsInputs) -> ReceiveSession { let new_state = - Receiver { state: WantsInputs { v1, session_context: self.state.session_context } }; + Receiver { state: WantsInputs { inner, session_context: self.state.session_context } }; ReceiveSession::WantsInputs(new_state) } } #[derive(Debug, Clone, PartialEq)] pub struct WantsInputs { - v1: v1::WantsInputs, + inner: common::WantsInputs, session_context: SessionContext, } @@ -822,7 +812,7 @@ impl Receiver { &self, candidate_inputs: impl IntoIterator, ) -> Result { - self.v1.try_preserving_privacy(candidate_inputs) + self.inner.try_preserving_privacy(candidate_inputs) } /// Contributes the provided list of inputs to the transaction at random indices. If the total input @@ -832,35 +822,34 @@ impl Receiver { self, inputs: impl IntoIterator, ) -> Result { - let inner = self.state.v1.contribute_inputs(inputs)?; - Ok(Receiver { - state: WantsInputs { v1: inner, session_context: self.state.session_context }, - }) + let inner = self.state.inner.contribute_inputs(inputs)?; + Ok(Receiver { state: WantsInputs { inner, session_context: self.state.session_context } }) } /// Commits the inputs as final, and moves on to the next typestate. /// /// Inputs cannot be modified after this function is called. pub fn commit_inputs(self) -> NextStateTransition> { - let inner = self.state.v1.clone().commit_inputs(); + let inner = self.state.inner.clone().commit_inputs(); NextStateTransition::success( SessionEvent::WantsFeeRange(inner.clone()), Receiver { - state: WantsFeeRange { v1: inner, session_context: self.state.session_context }, + state: WantsFeeRange { inner, session_context: self.state.session_context }, }, ) } - pub(crate) fn apply_wants_fee_range(self, v1: v1::WantsFeeRange) -> ReceiveSession { - let new_state = - Receiver { state: WantsFeeRange { v1, session_context: self.state.session_context } }; + pub(crate) fn apply_wants_fee_range(self, inner: common::WantsFeeRange) -> ReceiveSession { + let new_state = Receiver { + state: WantsFeeRange { inner, session_context: self.state.session_context }, + }; ReceiveSession::WantsFeeRange(new_state) } } #[derive(Debug, Clone, PartialEq)] pub struct WantsFeeRange { - v1: v1::WantsFeeRange, + inner: common::WantsFeeRange, session_context: SessionContext, } @@ -892,7 +881,11 @@ impl Receiver { min_fee_rate: Option, max_effective_fee_rate: Option, ) -> MaybeFatalTransition, ReplyableError> { - let inner = match self.state.v1.apply_fee_range(min_fee_rate, max_effective_fee_rate) { + let psbt_context = match self + .state + .inner + .apply_fee_to_psbt_context(min_fee_rate, max_effective_fee_rate) + { Ok(inner) => inner, Err(e) => { return MaybeFatalTransition::fatal( @@ -902,20 +895,20 @@ impl Receiver { } }; MaybeFatalTransition::success( - SessionEvent::ProvisionalProposal(inner.clone()), + SessionEvent::ProvisionalProposal(psbt_context.clone()), Receiver { state: ProvisionalProposal { - psbt_context: inner.psbt_context, + psbt_context, session_context: self.state.session_context.clone(), }, }, ) } - pub(crate) fn apply_provisional_proposal(self, v1: v1::ProvisionalProposal) -> ReceiveSession { + pub(crate) fn apply_provisional_proposal(self, psbt_context: PsbtContext) -> ReceiveSession { let new_state = Receiver { state: ProvisionalProposal { - psbt_context: v1.psbt_context, + psbt_context, session_context: self.state.session_context, }, }; @@ -1322,7 +1315,7 @@ pub mod test { let context = SessionContext { expiry: now, ..SHARED_CONTEXT.clone() }; let receiver = Receiver { state: UncheckedProposal { - original: crate::receive::v1::test::proposal_from_test_vector(), + original: crate::receive::tests::original_from_test_vector(), session_context: context.clone(), }, }; diff --git a/payjoin/src/core/receive/v2/session.rs b/payjoin/src/core/receive/v2/session.rs index 0c9028f77..5f3b7c44d 100644 --- a/payjoin/src/core/receive/v2/session.rs +++ b/payjoin/src/core/receive/v2/session.rs @@ -6,7 +6,7 @@ use super::{ReceiveSession, Receiver, SessionContext, UninitializedReceiver}; use crate::output_substitution::OutputSubstitution; use crate::persist::SessionPersister; use crate::receive::v2::{extract_err_req, SessionError}; -use crate::receive::{v1, JsonReply, Original}; +use crate::receive::{common, JsonReply, Original, PsbtContext}; use crate::{ImplementationError, IntoUrl, PjUri, Request}; /// Errors that can occur when replaying a receiver event log @@ -111,8 +111,8 @@ impl SessionHistory { /// Psbt with fee contributions applied pub fn psbt_ready_for_signing(&self) -> Option { self.events.iter().find_map(|event| match event { - SessionEvent::ProvisionalProposal(proposal) => - Some(proposal.psbt_context.payjoin_psbt.clone()), + SessionEvent::ProvisionalProposal(psbt_context) => + Some(psbt_context.payjoin_psbt.clone()), _ => None, }) } @@ -160,10 +160,10 @@ pub enum SessionEvent { MaybeInputsOwned(), MaybeInputsSeen(), OutputsUnknown(), - WantsOutputs(v1::WantsOutputs), - WantsInputs(v1::WantsInputs), - WantsFeeRange(v1::WantsFeeRange), - ProvisionalProposal(v1::ProvisionalProposal), + WantsOutputs(common::WantsOutputs), + WantsInputs(common::WantsInputs), + WantsFeeRange(common::WantsFeeRange), + ProvisionalProposal(PsbtContext), PayjoinProposal(bitcoin::Psbt), /// Session is invalid. This is a irrecoverable error. Fallback tx should be broadcasted. /// TODO this should be any error type that is impl std::error and works well with serde, or as a fallback can be formatted as a string @@ -178,50 +178,74 @@ mod tests { use super::*; use crate::persist::test_utils::InMemoryTestPersister; - use crate::receive::v1::test::{ - proposal_from_test_vector, unchecked_proposal_from_test_vector, - }; + use crate::persist::NoopSessionPersister; + use crate::receive::tests::original_from_test_vector; use crate::receive::v2::test::SHARED_CONTEXT; use crate::receive::v2::{ Initialized, MaybeInputsOwned, PayjoinProposal, ProvisionalProposal, UncheckedProposal, }; + fn unchecked_receiver_from_test_vector() -> Receiver { + Receiver { + state: UncheckedProposal { + original: original_from_test_vector(), + session_context: SHARED_CONTEXT.clone(), + }, + } + } + #[test] fn test_session_event_serialization_roundtrip() { - let proposal = crate::receive::v1::test::proposal_from_test_vector(); - let unchecked_proposal = unchecked_proposal_from_test_vector(); - let maybe_inputs_owned = unchecked_proposal.clone().assume_interactive_receiver(); + let persister = NoopSessionPersister::::default(); + + let original = original_from_test_vector(); + let unchecked_proposal = unchecked_receiver_from_test_vector(); + let maybe_inputs_owned = unchecked_proposal + .clone() + .assume_interactive_receiver() + .save(&persister) + .expect("Save should not fail"); let maybe_inputs_seen = maybe_inputs_owned .clone() .check_inputs_not_owned(&mut |_| Ok(false)) + .save(&persister) .expect("No inputs should be owned"); let outputs_unknown = maybe_inputs_seen .clone() .check_no_inputs_seen_before(&mut |_| Ok(false)) + .save(&persister) .expect("No inputs should be seen before"); let wants_outputs = outputs_unknown .clone() .identify_receiver_outputs(&mut |_| Ok(true)) + .save(&persister) .expect("Outputs should be identified"); - let wants_inputs = wants_outputs.clone().commit_outputs(); - let wants_fee_range = wants_inputs.clone().commit_inputs(); - let provisional_proposal = wants_fee_range.clone().apply_fee_range(None, None).unwrap(); + let wants_inputs = + wants_outputs.clone().commit_outputs().save(&persister).expect("Save should not fail"); + let wants_fee_range = + wants_inputs.clone().commit_inputs().save(&persister).expect("Save should not fail"); + let provisional_proposal = wants_fee_range + .clone() + .apply_fee_range(None, None) + .save(&persister) + .expect("Save should not fail"); let payjoin_proposal = provisional_proposal .clone() .finalize_proposal(|psbt| Ok(psbt.clone())) + .save(&persister) .expect("Payjoin proposal should be finalized"); let test_cases = vec![ SessionEvent::Created(SHARED_CONTEXT.clone()), - SessionEvent::UncheckedProposal((proposal.clone(), None)), - SessionEvent::UncheckedProposal((proposal, Some(crate::HpkeKeyPair::gen_keypair().1))), + SessionEvent::UncheckedProposal((original.clone(), None)), + SessionEvent::UncheckedProposal((original, Some(crate::HpkeKeyPair::gen_keypair().1))), SessionEvent::MaybeInputsOwned(), SessionEvent::MaybeInputsSeen(), SessionEvent::OutputsUnknown(), - SessionEvent::WantsOutputs(wants_outputs), - SessionEvent::WantsInputs(wants_inputs), - SessionEvent::WantsFeeRange(wants_fee_range), - SessionEvent::ProvisionalProposal(provisional_proposal), + SessionEvent::WantsOutputs(wants_outputs.state.inner.clone()), + SessionEvent::WantsInputs(wants_inputs.state.inner.clone()), + SessionEvent::WantsFeeRange(wants_fee_range.state.inner.clone()), + SessionEvent::ProvisionalProposal(provisional_proposal.state.psbt_context.clone()), SessionEvent::PayjoinProposal(payjoin_proposal.psbt().clone()), ]; @@ -279,19 +303,19 @@ mod tests { #[test] fn test_replaying_unchecked_proposal() -> Result<(), BoxError> { let session_context = SHARED_CONTEXT.clone(); - let proposal = proposal_from_test_vector(); + let original = original_from_test_vector(); let test = SessionHistoryTest { events: vec![ SessionEvent::Created(session_context.clone()), - SessionEvent::UncheckedProposal((proposal.clone(), None)), + SessionEvent::UncheckedProposal((original.clone(), None)), ], expected_session_history: SessionHistoryExpectedOutcome { psbt_with_fee_contributions: None, fallback_tx: None, }, expected_receiver_state: ReceiveSession::UncheckedProposal(Receiver { - state: UncheckedProposal { original: proposal, session_context }, + state: UncheckedProposal { original, session_context }, }), }; run_session_history_test(test) @@ -301,19 +325,19 @@ mod tests { fn test_replaying_unchecked_proposal_expiry() { let now = SystemTime::now(); let context = SessionContext { expiry: now, ..SHARED_CONTEXT.clone() }; - let proposal = proposal_from_test_vector(); + let original = original_from_test_vector(); let test = SessionHistoryTest { events: vec![ SessionEvent::Created(context.clone()), - SessionEvent::UncheckedProposal((proposal.clone(), None)), + SessionEvent::UncheckedProposal((original.clone(), None)), ], expected_session_history: SessionHistoryExpectedOutcome { psbt_with_fee_contributions: None, fallback_tx: None, }, expected_receiver_state: ReceiveSession::UncheckedProposal(Receiver { - state: UncheckedProposal { original: proposal, session_context: context }, + state: UncheckedProposal { original, session_context: context }, }), }; let session_history = run_session_history_test(test); @@ -330,19 +354,19 @@ mod tests { #[test] fn test_replaying_unchecked_proposal_with_reply_key() -> Result<(), BoxError> { let session_context = SHARED_CONTEXT.clone(); - let proposal = proposal_from_test_vector(); + let original = original_from_test_vector(); let test = SessionHistoryTest { events: vec![ SessionEvent::Created(session_context.clone()), - SessionEvent::UncheckedProposal((proposal.clone(), session_context.e.clone())), + SessionEvent::UncheckedProposal((original.clone(), session_context.e.clone())), ], expected_session_history: SessionHistoryExpectedOutcome { psbt_with_fee_contributions: None, fallback_tx: None, }, expected_receiver_state: ReceiveSession::UncheckedProposal(Receiver { - state: UncheckedProposal { original: proposal, session_context }, + state: UncheckedProposal { original, session_context }, }), }; run_session_history_test(test) @@ -350,15 +374,18 @@ mod tests { #[test] fn getting_fallback_tx() -> Result<(), BoxError> { + let persister = NoopSessionPersister::::default(); let session_context = SHARED_CONTEXT.clone(); let mut events = vec![]; - let proposal = proposal_from_test_vector(); - let maybe_inputs_owned = - unchecked_proposal_from_test_vector().assume_interactive_receiver(); + let original = original_from_test_vector(); + let maybe_inputs_owned = unchecked_receiver_from_test_vector() + .assume_interactive_receiver() + .save(&persister) + .unwrap(); let expected_fallback = maybe_inputs_owned.extract_tx_to_schedule_broadcast(); events.push(SessionEvent::Created(session_context.clone())); - events.push(SessionEvent::UncheckedProposal((proposal.clone(), None))); + events.push(SessionEvent::UncheckedProposal((original.clone(), None))); events.push(SessionEvent::MaybeInputsOwned()); let test = SessionHistoryTest { @@ -368,7 +395,7 @@ mod tests { fallback_tx: Some(expected_fallback), }, expected_receiver_state: ReceiveSession::MaybeInputsOwned(Receiver { - state: MaybeInputsOwned { original: proposal, session_context }, + state: MaybeInputsOwned { original, session_context }, }), }; run_session_history_test(test) @@ -376,53 +403,64 @@ mod tests { #[test] fn test_contributed_inputs() -> Result<(), BoxError> { + let persister = InMemoryTestPersister::::default(); let session_context = SHARED_CONTEXT.clone(); let mut events = vec![]; - let proposal = proposal_from_test_vector(); - let maybe_inputs_owned = - unchecked_proposal_from_test_vector().assume_interactive_receiver(); + let original = original_from_test_vector(); + let maybe_inputs_owned = unchecked_receiver_from_test_vector() + .assume_interactive_receiver() + .save(&persister) + .unwrap(); let maybe_inputs_seen = maybe_inputs_owned .clone() .check_inputs_not_owned(&mut |_| Ok(false)) + .save(&persister) .expect("No inputs should be owned"); let outputs_unknown = maybe_inputs_seen .clone() .check_no_inputs_seen_before(&mut |_| Ok(false)) + .save(&persister) .expect("No inputs should be seen before"); let wants_outputs = outputs_unknown .clone() .identify_receiver_outputs(&mut |_| Ok(true)) + .save(&persister) .expect("Outputs should be identified"); - let wants_inputs = wants_outputs.clone().commit_outputs(); - let wants_fee_range = wants_inputs.clone().commit_inputs(); + let wants_inputs = + wants_outputs.clone().commit_outputs().save(&persister).expect("Save should not fail"); + let wants_fee_range = + wants_inputs.clone().commit_inputs().save(&persister).expect("Save should not fail"); let provisional_proposal = wants_fee_range .clone() .apply_fee_range(None, None) + .save(&persister) .expect("Contributed inputs should be valid"); let expected_fallback = maybe_inputs_owned.extract_tx_to_schedule_broadcast(); events.push(SessionEvent::Created(session_context.clone())); - events.push(SessionEvent::UncheckedProposal((proposal.clone(), None))); + events.push(SessionEvent::UncheckedProposal((original.clone(), None))); events.push(SessionEvent::MaybeInputsOwned()); events.push(SessionEvent::MaybeInputsSeen()); events.push(SessionEvent::OutputsUnknown()); - events.push(SessionEvent::WantsOutputs(wants_outputs)); - events.push(SessionEvent::WantsInputs(wants_inputs)); - events.push(SessionEvent::WantsFeeRange(wants_fee_range)); - events.push(SessionEvent::ProvisionalProposal(provisional_proposal.clone())); + events.push(SessionEvent::WantsOutputs(wants_outputs.state.inner.clone())); + events.push(SessionEvent::WantsInputs(wants_inputs.state.inner.clone())); + events.push(SessionEvent::WantsFeeRange(wants_fee_range.state.inner.clone())); + events.push(SessionEvent::ProvisionalProposal( + provisional_proposal.state.psbt_context.clone(), + )); let test = SessionHistoryTest { events, expected_session_history: SessionHistoryExpectedOutcome { psbt_with_fee_contributions: Some( - provisional_proposal.psbt_context.payjoin_psbt.clone(), + provisional_proposal.state.psbt_context.payjoin_psbt.clone(), ), fallback_tx: Some(expected_fallback), }, expected_receiver_state: ReceiveSession::ProvisionalProposal(Receiver { state: ProvisionalProposal { - psbt_context: provisional_proposal.psbt_context, + psbt_context: provisional_proposal.state.psbt_context.clone(), session_context, }, }), @@ -432,52 +470,64 @@ mod tests { #[test] fn test_payjoin_proposal() -> Result<(), BoxError> { + let persister = NoopSessionPersister::::default(); let session_context = SHARED_CONTEXT.clone(); let mut events = vec![]; - let proposal = proposal_from_test_vector(); - let maybe_inputs_owned = - unchecked_proposal_from_test_vector().assume_interactive_receiver(); + let original = original_from_test_vector(); + let maybe_inputs_owned = unchecked_receiver_from_test_vector() + .assume_interactive_receiver() + .save(&persister) + .unwrap(); let maybe_inputs_seen = maybe_inputs_owned .clone() .check_inputs_not_owned(&mut |_| Ok(false)) + .save(&persister) .expect("No inputs should be owned"); let outputs_unknown = maybe_inputs_seen .clone() .check_no_inputs_seen_before(&mut |_| Ok(false)) + .save(&persister) .expect("No inputs should be seen before"); let wants_outputs = outputs_unknown .clone() .identify_receiver_outputs(&mut |_| Ok(true)) + .save(&persister) .expect("Outputs should be identified"); - let wants_inputs = wants_outputs.clone().commit_outputs(); - let wants_fee_range = wants_inputs.clone().commit_inputs(); + let wants_inputs = + wants_outputs.clone().commit_outputs().save(&persister).expect("Save should not fail"); + let wants_fee_range = + wants_inputs.clone().commit_inputs().save(&persister).expect("Save should not fail"); let provisional_proposal = wants_fee_range .clone() .apply_fee_range(None, None) + .save(&persister) .expect("Contributed inputs should be valid"); let payjoin_proposal = provisional_proposal .clone() .finalize_proposal(|psbt| Ok(psbt.clone())) + .save(&persister) .expect("Payjoin proposal should be finalized"); let expected_fallback = maybe_inputs_owned.extract_tx_to_schedule_broadcast(); events.push(SessionEvent::Created(session_context.clone())); - events.push(SessionEvent::UncheckedProposal((proposal.clone(), None))); + events.push(SessionEvent::UncheckedProposal((original.clone(), None))); events.push(SessionEvent::MaybeInputsOwned()); events.push(SessionEvent::MaybeInputsSeen()); events.push(SessionEvent::OutputsUnknown()); - events.push(SessionEvent::WantsOutputs(wants_outputs)); - events.push(SessionEvent::WantsInputs(wants_inputs)); - events.push(SessionEvent::WantsFeeRange(wants_fee_range)); - events.push(SessionEvent::ProvisionalProposal(provisional_proposal.clone())); + events.push(SessionEvent::WantsOutputs(wants_outputs.state.inner.clone())); + events.push(SessionEvent::WantsInputs(wants_inputs.state.inner.clone())); + events.push(SessionEvent::WantsFeeRange(wants_fee_range.state.inner.clone())); + events.push(SessionEvent::ProvisionalProposal( + provisional_proposal.state.psbt_context.clone(), + )); events.push(SessionEvent::PayjoinProposal(payjoin_proposal.psbt().clone())); let test = SessionHistoryTest { events, expected_session_history: SessionHistoryExpectedOutcome { psbt_with_fee_contributions: Some( - provisional_proposal.psbt_context.payjoin_psbt.clone(), + provisional_proposal.state.psbt_context.payjoin_psbt.clone(), ), fallback_tx: Some(expected_fallback), },