diff --git a/payjoin/src/core/receive/common/mod.rs b/payjoin/src/core/receive/common/mod.rs index b37789ea7..61cfad58d 100644 --- a/payjoin/src/core/receive/common/mod.rs +++ b/payjoin/src/core/receive/common/mod.rs @@ -21,6 +21,55 @@ use crate::output_substitution::OutputSubstitution; use crate::psbt::PsbtExt; use crate::receive::{InternalPayloadError, OriginalPayload, PsbtContext}; +/// The session invariant shared by every receiver typestate from `WantsOutputs` +/// onward: the sender's original PSBT and the (sanitized) parameters that govern +/// the rest of the flow. +/// +/// These never change once the receiver has identified its outputs, so they are +/// stored once and re-threaded across the mutable typestates rather than +/// re-persisted in every commit event. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct OriginalContext { + pub(super) original_psbt: Psbt, + pub(super) params: Params, +} + +impl OriginalContext { + /// Build the session invariant from the raw original payload and the set of + /// receiver-owned output indices. + /// + /// This is the single place that sanitizes `additional_fee_contribution`: if the + /// sender pointed it at a receiver-owned output, the parameter is dropped so the + /// receiver never subtracts the sender's fee contribution from its own output. + /// Both the live `identify_receiver_outputs` path and the replay reconstruction + /// route through here so they cannot diverge. + pub(super) fn new(original_psbt: Psbt, mut params: Params, owned_vouts: &[usize]) -> Self { + if let Some((_, additional_fee_output_index)) = params.additional_fee_contribution { + // If the additional fee output index specified by the sender points to a + // receiver output, the receiver should ignore the parameter. + // https://github.com/bitcoin/bips/blob/master/bip-0078.mediawiki#optional-parameters + if owned_vouts.contains(&additional_fee_output_index) { + params.additional_fee_contribution = None; + } + } + Self { original_psbt, params } + } +} + +/// The working remainder of the receiver proposal: the working PSBT, the index of +/// the receiver change output within it, and the inputs the receiver contributed. +/// +/// `replace_receiver_outputs`/`contribute_inputs` mutate these via `thread_rng` +/// (output shuffles, random input insertion indices), so they cannot be +/// reconstructed from a summary. The commit events persist this struct verbatim +/// and replay copies it back without recomputation. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct WorkingProposal { + pub(super) payjoin_psbt: Psbt, + pub(super) change_vout: usize, + pub(super) receiver_inputs: Vec, +} + /// Typestate which the receiver may substitute or add outputs to. /// /// In addition to contributing new inputs to an existing PSBT, Payjoin allows the @@ -31,9 +80,8 @@ use crate::receive::{InternalPayloadError, OriginalPayload, PsbtContext}; /// Call [`Self::commit_outputs`] to proceed. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct WantsOutputs { - pub(super) original_psbt: Psbt, + pub(super) context: OriginalContext, pub(super) payjoin_psbt: Psbt, - pub(super) params: Params, change_vout: usize, pub(super) owned_vouts: Vec, } @@ -42,26 +90,29 @@ impl WantsOutputs { /// Create a new [`WantsOutputs`] typestate from an [`OriginalPayload`] typestate and a list of /// owned outputs. /// - /// The first output in the `owned_vouts` list is used as the `change_vout`. + /// The first output in the `owned_vouts` list is used as the `change_vout`. The + /// parameters are sanitized via [`OriginalContext::new`]. pub(super) fn new(original: OriginalPayload, owned_vouts: Vec) -> Self { - Self { - original_psbt: original.psbt.clone(), - payjoin_psbt: original.psbt, - params: original.params, - change_vout: owned_vouts[0], - owned_vouts, - } + let change_vout = owned_vouts[0]; + let context = OriginalContext::new(original.psbt.clone(), original.params, &owned_vouts); + Self { context, payjoin_psbt: original.psbt, change_vout, owned_vouts } } + /// The sender's original PSBT. + #[cfg(feature = "v2")] + pub(crate) fn original_psbt(&self) -> &Psbt { &self.context.original_psbt } + /// Returns whether the receiver is allowed to substitute original outputs or not. - pub fn output_substitution(&self) -> OutputSubstitution { self.params.output_substitution } + pub fn output_substitution(&self) -> OutputSubstitution { + self.context.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 output_value = self.context.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) } @@ -84,12 +135,28 @@ impl WantsOutputs { replacement_outputs: impl IntoIterator, drain_script: &Script, ) -> Result { - let mut payjoin_psbt = self.original_psbt.clone(); + self.replace_receiver_outputs_with_rng( + replacement_outputs, + drain_script, + &mut rand::thread_rng(), + ) + } + + /// [`replace_receiver_outputs`](Self::replace_receiver_outputs) with the output-shuffle + /// `rng` injected, so tests can seed it and assert against a fixed post-substitution + /// state. Production goes through the public wrapper, which supplies `rand::thread_rng()`. + pub(crate) fn replace_receiver_outputs_with_rng( + self, + replacement_outputs: impl IntoIterator, + drain_script: &Script, + rng: &mut R, + ) -> Result { + let mut payjoin_psbt = self.context.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() { + for (i, original_output) in self.context.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() { @@ -130,16 +197,15 @@ impl WantsOutputs { } } // Insert all remaining outputs at random indices for privacy - interleave_shuffle(&mut outputs, &mut replacement_outputs, &mut rng); + interleave_shuffle(&mut outputs, &mut replacement_outputs, 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, + context: self.context, payjoin_psbt, - params: self.params, change_vout: change_vout.ok_or(InternalOutputSubstitutionError::InvalidDrainScript)?, owned_vouts: self.owned_vouts, }) @@ -150,11 +216,12 @@ impl WantsOutputs { /// 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![], + context: self.context, + working: WorkingProposal { + payjoin_psbt: self.payjoin_psbt, + change_vout: self.change_vout, + receiver_inputs: vec![], + }, } } } @@ -189,14 +256,15 @@ fn interleave_shuffle(original: &mut Vec, new: &mut [ /// Call [`Self::commit_inputs`] to proceed. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct WantsInputs { - pub(super) original_psbt: Psbt, - pub(super) payjoin_psbt: Psbt, - pub(super) params: Params, - pub(super) change_vout: usize, - pub(super) receiver_inputs: Vec, + pub(super) context: OriginalContext, + pub(super) working: WorkingProposal, } impl WantsInputs { + /// The sender's original PSBT. + #[cfg(feature = "v2")] + pub(crate) fn original_psbt(&self) -> &Psbt { &self.context.original_psbt } + /// 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). @@ -226,11 +294,12 @@ impl WantsInputs { &self, candidate_inputs: &[InputPair], ) -> Result { - if self.payjoin_psbt.outputs.len() != 2 { + if self.working.payjoin_psbt.outputs.len() != 2 { return Err(InternalCoinSelectionError::UnsupportedOutputLength.into()); } let min_out_sats = self + .working .payjoin_psbt .unsigned_tx .output @@ -240,13 +309,15 @@ impl WantsInputs { .unwrap_or(Amount::MAX_MONEY); let min_in_sats = self + .working .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; + let prior_payment_sats = + self.working.payjoin_psbt.unsigned_tx.output[self.working.change_vout].value; for input_pair in candidate_inputs { let candidate_sats = input_pair.previous_txout().value; @@ -279,9 +350,10 @@ impl WantsInputs { self, inputs: impl IntoIterator, ) -> Result { - let mut payjoin_psbt = self.payjoin_psbt.clone(); + let mut payjoin_psbt = self.working.payjoin_psbt.clone(); // The payjoin proposal must not introduce mixed input sequence numbers let original_sequence = self + .context .original_psbt .unsigned_tx .input @@ -290,8 +362,14 @@ impl WantsInputs { .unwrap_or_default(); // Collect existing PSBT outpoints to detect duplicate inputs. - let mut seen_outpoints: HashSet<_> = - self.payjoin_psbt.unsigned_tx.input.iter().map(|txin| txin.previous_output).collect(); + let mut seen_outpoints: HashSet<_> = self + .working + .payjoin_psbt + .unsigned_tx + .input + .iter() + .map(|txin| txin.previous_output) + .collect(); let inputs: Vec<_> = inputs.into_iter().collect(); for input in &inputs { if !seen_outpoints.insert(input.txin.previous_output) { @@ -307,7 +385,7 @@ impl WantsInputs { 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()); + let index = rng.gen_range(0..=self.working.payjoin_psbt.unsigned_tx.input.len()); payjoin_psbt.inputs.insert(index, input_pair.psbtin); payjoin_psbt .unsigned_tx @@ -319,32 +397,35 @@ impl WantsInputs { 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; + payjoin_psbt.unsigned_tx.output[self.working.change_vout].value += change_amount; } else { return Err(InternalInputContributionError::ValueTooLow.into()); } - let mut receiver_inputs = self.receiver_inputs; + let mut receiver_inputs = self.working.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, + context: self.context, + working: WorkingProposal { + payjoin_psbt, + change_vout: self.working.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 + .working .payjoin_psbt .unsigned_tx .output .iter() .fold(Amount::ZERO, |acc, output| acc + output.value); let original_output_amount = self + .context .original_psbt .unsigned_tx .output @@ -357,26 +438,21 @@ impl WantsInputs { /// /// 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, - } + WantsFeeRange { context: self.context, working: self.working } } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct WantsFeeRange { - pub(super) original_psbt: Psbt, - pub(super) payjoin_psbt: Psbt, - pub(super) params: Params, - pub(super) change_vout: usize, - pub(super) receiver_inputs: Vec, + pub(super) context: OriginalContext, + pub(super) working: WorkingProposal, } impl WantsFeeRange { + /// The sender's original PSBT. + #[cfg(feature = "v2")] + pub(crate) fn original_psbt(&self) -> &Psbt { &self.context.original_psbt } + /// Calculate the Payjoin PSBT with the specified fee range. /// /// This function is idempotent. It can be called multiple times with the same @@ -388,13 +464,13 @@ impl WantsFeeRange { ) -> Result { let min_fee_rate = min_fee_rate.unwrap_or(FeeRate::BROADCAST_MIN); tracing::trace!("min_fee_rate: {min_fee_rate:?}"); - tracing::trace!("params.min_fee_rate: {:?}", self.params.min_fee_rate); - let min_fee_rate = max(min_fee_rate, self.params.min_fee_rate); + tracing::trace!("params.min_fee_rate: {:?}", self.context.params.min_fee_rate); + let min_fee_rate = max(min_fee_rate, self.context.params.min_fee_rate); tracing::debug!("min_fee_rate: {min_fee_rate:?}"); let max_fee_rate = max_effective_fee_rate.unwrap_or(FeeRate::BROADCAST_MIN); - let mut payjoin_psbt = self.payjoin_psbt.clone(); + let mut payjoin_psbt = self.working.payjoin_psbt.clone(); // 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 @@ -406,16 +482,16 @@ impl WantsFeeRange { if additional_fee >= Amount::ONE_SAT { tracing::trace!( "self.params.additional_fee_contribution: {:?}", - self.params.additional_fee_contribution + self.context.params.additional_fee_contribution ); if let Some((max_additional_fee_contribution, additional_fee_output_index)) = - self.params.additional_fee_contribution + self.context.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]; + &self.context.original_psbt.unsigned_tx.output[additional_fee_output_index]; // Find the index of that output in the payjoin psbt let sender_fee_vout = payjoin_psbt .unsigned_tx @@ -448,25 +524,28 @@ impl WantsFeeRange { } if receiver_additional_fee >= Amount::ONE_SAT { // Remove additional miner fee from the receiver's specified output - payjoin_psbt.unsigned_tx.output[self.change_vout].value -= receiver_additional_fee; + payjoin_psbt.unsigned_tx.output[self.working.change_vout].value -= + receiver_additional_fee; } Ok(payjoin_psbt) } /// Calculate the additional input weight contributed by the receiver. fn additional_input_weight(&self) -> Weight { - self.receiver_inputs.iter().map(|input_pair| input_pair.expected_weight).sum() + self.working.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 + .working .payjoin_psbt .unsigned_tx .output .iter() .fold(Weight::ZERO, |acc, txo| acc + txo.weight()); let original_outputs_weight = self + .context .original_psbt .unsigned_tx .output @@ -487,7 +566,7 @@ impl WantsFeeRange { ) -> Result { let payjoin_psbt = self.calculate_psbt_with_fee_range(min_fee_rate, max_effective_fee_rate)?; - Ok(PsbtContext { original_psbt: self.original_psbt, payjoin_psbt }) + Ok(PsbtContext { original_psbt: self.context.original_psbt, payjoin_psbt }) } } @@ -530,12 +609,12 @@ mod tests { 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 + let script_pubkey = &wants_outputs.context.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; + wants_outputs.context.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()); @@ -546,7 +625,7 @@ mod tests { 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 + wants_outputs.context.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 = @@ -558,7 +637,7 @@ mod tests { 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 + wants_outputs.context.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 = @@ -620,8 +699,8 @@ mod tests { fn try_preserving_privacy_falls_back_when_avoid_uih_unsupported() { let original = original_from_test_vector(); let mut wants_inputs = WantsOutputs::new(original, vec![0]).commit_outputs(); - wants_inputs.payjoin_psbt.unsigned_tx.output.pop(); - wants_inputs.payjoin_psbt.outputs.pop(); + wants_inputs.working.payjoin_psbt.unsigned_tx.output.pop(); + wants_inputs.working.payjoin_psbt.outputs.pop(); let candidate = candidate_input_from_test_vector(Amount::ONE_SAT); let selected = wants_inputs.try_preserving_privacy([candidate.clone()]).unwrap(); @@ -644,7 +723,7 @@ mod tests { .contribute_inputs([input.clone()]) .expect("Failed to contribute inputs"); - payjoin.payjoin_psbt.outputs.pop(); + payjoin.working.payjoin_psbt.outputs.pop(); let avoid_uih = payjoin.avoid_uih(std::slice::from_ref(&input)); assert_eq!( avoid_uih.unwrap_err(), @@ -706,8 +785,8 @@ mod tests { .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); + assert_eq!(wants_inputs.working.receiver_inputs.len(), 1); + assert_eq!(wants_inputs.working.receiver_inputs[0], input_pair_1); // Contribute the same input again (should error) and a new input. let duplicate_input = wants_inputs .clone() @@ -719,9 +798,9 @@ mod tests { ); // Contribute only the new input let wants_inputs = wants_inputs.contribute_inputs(vec![input_pair_2.clone()]).unwrap(); - assert_eq!(wants_inputs.receiver_inputs.len(), 2); - assert_eq!(wants_inputs.receiver_inputs[0], input_pair_1); - assert_eq!(wants_inputs.receiver_inputs[1], input_pair_2); + assert_eq!(wants_inputs.working.receiver_inputs.len(), 2); + assert_eq!(wants_inputs.working.receiver_inputs[0], input_pair_1); + assert_eq!(wants_inputs.working.receiver_inputs[1], input_pair_2); } #[test] @@ -747,9 +826,11 @@ mod tests { .commit_inputs(); let additional_output = TxOut { value: Amount::ZERO, - script_pubkey: payjoin.original_psbt.unsigned_tx.output[0].script_pubkey.clone(), + script_pubkey: payjoin.context.original_psbt.unsigned_tx.output[0] + .script_pubkey + .clone(), }; - payjoin.payjoin_psbt.unsigned_tx.output.push(additional_output); + payjoin.working.payjoin_psbt.unsigned_tx.output.push(additional_output); let psbt = payjoin.calculate_psbt_with_fee_range(None, Some(FeeRate::from_sat_per_vb_u32(1000))); assert!(psbt.is_ok(), "Payjoin should be a valid PSBT"); @@ -757,7 +838,12 @@ mod tests { payjoin.calculate_psbt_with_fee_range(None, Some(FeeRate::from_sat_per_vb_u32(995))); match psbt { Err(InternalPayloadError::FeeTooHigh(proposed, max)) => { - assert_eq!(FeeRate::from_str("249630").unwrap(), proposed); + // The owned vout (0) is also the sender's `additionalfeeoutputindex`, + // so `OriginalContext::new` drops `additional_fee_contribution`: the + // sender cannot subsidize fees on a receiver-owned output. The receiver + // therefore pays the full additional fee, yielding a higher rate than if + // the (incorrectly un-sanitized) sender contribution had applied. + assert_eq!(FeeRate::from_str("250000").unwrap(), proposed); assert_eq!(FeeRate::from_sat_per_vb_u32(995), max); } _ => panic!( @@ -772,11 +858,11 @@ mod tests { // 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![ + context: OriginalContext { 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(), params: Params::default() }, + working: WorkingProposal { + payjoin_psbt: Psbt::from_str("cHNidP8BAJoCAAAAAtTRxwAtk38fRMP3ffdKkIi5r+Ss9AjaO8qEv+eQ/ho3AAAAAAD9////vaqF6DLjuGp9/GH9QflCN38bewEfpxbZBJdnzqdtjecAAAAAAP3///8CgckFKgEAAAAWABThOIsUPXhhul10VWtlrf5mbP3rJBAZBioBAAAAFgAUiDIby0wSbj1kv3MlvwoEKw3vNZUAAAAAAAEAhwIAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////AwFoAP////8CAPIFKgEAAAAZdqkUPXhu3I6D9R0wUpvTvvUm+VGNcNuIrAAAAAAAAAAAJmokqiGp7eL2HD9x0d79P6mZ36NpU3VcaQaJeZlitIvr2DaXToz5AAAAAAEBIgDyBSoBAAAAGXapFD14btyOg/UdMFKb0771JvlRjXDbiKwBB2pHMEQCIGzKy8QfhHoAY0+LZCpQ7ZOjyyXqaSBnr89hH3Eg/xsGAiB3n8hPRuXCX/iWtURfXoJNUFu3sLeQVFf1dDFCZPN0dAEhA8rTfrwcq6dEBSNOrUfNb8+dm7q77vCtfdOmWx0HfajRAAEAhwIAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////AwFKAP////8CAPIFKgEAAAAZdqkUZTGlTpwaV1WmNSb2tXYH6NOWmEOIrAAAAAAAAAAAJmokqiGp7eL2HD9x0d79P6mZ36NpU3VcaQaJeZlitIvr2DaXToz5AAAAAAAAAA==").unwrap(), + change_vout: 0, + receiver_inputs: vec![ InputPair::new( TxIn{ previous_output: OutPoint::from_str("371afe90e7bf84ca3bda08f4ace4afb988904af77df7c3441f7f932d00c7d1d4:0").unwrap(), @@ -789,16 +875,17 @@ mod tests { ..Default::default() }, None) .unwrap()], + }, }; assert_eq!(p2pkh_proposal.additional_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![ + context: OriginalContext { original_psbt: Psbt::from_str("cHNidP8BAHECAAAAAeOsT9cRWRz3te+bgmtweG1vDLkdSH4057NuoodDNPFWAAAAAAD9////AhAnAAAAAAAAFgAUtp3bPFM/YWThyxD5Cc9OR4mb8tdMygUqAQAAABYAFODlplDoE6EGlZvmqoUngBgsu8qCAAAAAAABAIUCAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////wMBZwD/////AgDyBSoBAAAAF6kU2JnIn4Mmcb5kuF3EYeFei8IB43qHAAAAAAAAAAAmaiSqIant4vYcP3HR3v0/qZnfo2lTdVxpBol5mWK0i+vYNpdOjPkAAAAAAQEgAPIFKgEAAAAXqRTYmcifgyZxvmS4XcRh4V6LwgHjeocBBxcWABSPGoPK1yl60X4Z9OfA7IQPUWCgVwEIawJHMEQCICZG3s2cbulPnLTvK4TwlKhsC+cem8tD2GjZZ3eMJD7FAiADh/xwv0ib8ksOrj1M27DYLiw7WFptxkMkE2YgiNMRVgEhAlDMm5DA8kU+QGiPxEWUyV1S8+XGzUOepUOck257ZOhkAAAiAgP+oMbeca66mt+UtXgHm6v/RIFEpxrwG7IvPDim5KWHpBgfVHrXVAAAgAEAAIAAAACAAQAAAAAAAAAA").unwrap(), params: Params::default() }, + working: WorkingProposal { + payjoin_psbt: Psbt::from_str("cHNidP8BAJoCAAAAAuXYOTUaVRiB8cPPhEXzcJ72/SgZOPEpPx5pkG0fNeGCAAAAAAD9////46xP1xFZHPe175uCa3B4bW8MuR1IfjTns26ih0M08VYAAAAAAP3///8CEBkGKgEAAAAWABQHuuu4H4fbQWV51IunoJLUtmMTfEzKBSoBAAAAFgAU4OWmUOgToQaVm+aqhSeAGCy7yoIAAAAAAAEBIADyBSoBAAAAF6kUQ4BssmVBS3r0s95c6dl1DQCHCR+HAQQWABQbDc333XiiOeEXroP523OoYNb1aAABAIUCAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////wMBZwD/////AgDyBSoBAAAAF6kU2JnIn4Mmcb5kuF3EYeFei8IB43qHAAAAAAAAAAAmaiSqIant4vYcP3HR3v0/qZnfo2lTdVxpBol5mWK0i+vYNpdOjPkAAAAAAQEgAPIFKgEAAAAXqRTYmcifgyZxvmS4XcRh4V6LwgHjeocBBxcWABSPGoPK1yl60X4Z9OfA7IQPUWCgVwEIawJHMEQCICZG3s2cbulPnLTvK4TwlKhsC+cem8tD2GjZZ3eMJD7FAiADh/xwv0ib8ksOrj1M27DYLiw7WFptxkMkE2YgiNMRVgEhAlDMm5DA8kU+QGiPxEWUyV1S8+XGzUOepUOck257ZOhkAAAA").unwrap(), + change_vout: 0, + receiver_inputs: vec![ InputPair::new( TxIn { previous_output: OutPoint::from_str("82e1351f6d90691e3f29f1381928fdf69e70f34584cfc3f18118551a3539d8e5:0").unwrap(), @@ -813,16 +900,17 @@ mod tests { ..Default::default() }, None) .unwrap()], + }, }; assert_eq!(nested_p2wpkh_proposal.additional_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![ + context: OriginalContext { original_psbt: Psbt::from_str("cHNidP8BAHECAAAAASom13OiXZIr3bKk+LtUndZJYqdHQQU8dMs1FZ93IctIAAAAAAD9////AmPKBSoBAAAAFgAU6H98YM9NE1laARQ/t9/90nFraf4QJwAAAAAAABYAFBPJFmYuJBsrIaBBp9ur98pMSKxhAAAAAAABAIQCAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////wMBWwD/////AgDyBSoBAAAAFgAUjTJXmC73n+URSNdfgbS6Oa6JyQYAAAAAAAAAACZqJKohqe3i9hw/cdHe/T+pmd+jaVN1XGkGiXmZYrSL69g2l06M+QAAAAABAR8A8gUqAQAAABYAFI0yV5gu95/lEUjXX4G0ujmuickGAQhrAkcwRAIgUqbHS0difIGTRwN56z2/EiqLQFWerfJspyjuwsGSCXcCIA3IRTu8FVgniU5E4gecAMeegVnlTbTVfFyusWhQ2kVVASEDChVRm26KidHNWLdCLBTq5jspGJr+AJyyMqmUkvPkwFsAIgIDeBqmRB3ESjFWIp+wUXn/adGZU3kqWGjdkcnKpk8bAyUY94v8N1QAAIABAACAAAAAgAEAAAAAAAAAAAA=").unwrap(), params: Params::default() }, + working: WorkingProposal { + payjoin_psbt: Psbt::from_str("cHNidP8BAJoCAAAAAiom13OiXZIr3bKk+LtUndZJYqdHQQU8dMs1FZ93IctIAAAAAAD9////NG21aH8Vat3thaVmPvWDV/lvRmymFHeePcfUjlyngHIAAAAAAP3///8CH8oFKgEAAAAWABTof3xgz00TWVoBFD+33/3ScWtp/hAZBioBAAAAFgAU1mbnqky3bMxfmm0OgFaQCAs5fsoAAAAAAAEAhAIAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////AwFbAP////8CAPIFKgEAAAAWABSNMleYLvef5RFI11+BtLo5ronJBgAAAAAAAAAAJmokqiGp7eL2HD9x0d79P6mZ36NpU3VcaQaJeZlitIvr2DaXToz5AAAAAAEBHwDyBSoBAAAAFgAUjTJXmC73n+URSNdfgbS6Oa6JyQYAAQCEAgAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP////8DAWcA/////wIA8gUqAQAAABYAFJFtkfHTt3y1EDMaN6CFjjNWtpCRAAAAAAAAAAAmaiSqIant4vYcP3HR3v0/qZnfo2lTdVxpBol5mWK0i+vYNpdOjPkAAAAAAQEfAPIFKgEAAAAWABSRbZHx07d8tRAzGjeghY4zVraQkQEIawJHMEQCIDTC49IB9AnItqd8zy5RDc05f2ApBAfJ5x4zYfj3bsD2AiAQvvSt5ipScHcUwdlYB9vFnEi68hmh55M5a5e+oWvxMAEhAqErVSVulFb97/r5KQryOS1Xgghff8R7AOuEnvnmslQ5AAAA").unwrap(), + change_vout: 0, + receiver_inputs: vec![ InputPair::new( TxIn { previous_output: OutPoint::from_str("7280a75c8ed4c73d9e7714a66c466ff95783f53e66a585eddd6a157f68b56d34:0").unwrap(), @@ -835,17 +923,18 @@ mod tests { ..Default::default() }, None) .unwrap()], + }, }; assert_eq!(p2wpkh_proposal.additional_input_weight(), Weight::from_wu(272)); // Input weight for a single P2TR (taproot) receiver input // P2TR without witness requires explicit weight since spend type is unknown. 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![ + context: OriginalContext { original_psbt: Psbt::from_str("cHNidP8BAHECAAAAAU/CHxd1oi9Lq1xOD2GnHe0hsQdGJ2mkpYkmeasTj+w1AAAAAAD9////Am3KBSoBAAAAFgAUqJL/PDPnHeihhNhukTz8QEdZbZAQJwAAAAAAABYAFInyO0NQF7YR22Sm0YTPGm6yf19YAAAAAAABASsA8gUqAQAAACJRIGOPekNKFs9ASLj3FdlCLiou/jdPUegJGzlA111A80MAAQhCAUC3zX8eSeL8+bAo6xO0cpon83UsJdttiuwfMn/pBwub82rzMsoS6HZNXzg7hfcB3p1uj8JmqsBkZwm8k6fnU2peACICA+u+FjwmhEgWdjhEQbO49D0NG8iCYUoqhlfsj0LN7hiRGOcVI65UAACAAQAAgAAAAIABAAAAAAAAAAAA").unwrap(), params: Params::default() }, + working: WorkingProposal { + payjoin_psbt: Psbt::from_str("cHNidP8BAJoCAAAAAk/CHxd1oi9Lq1xOD2GnHe0hsQdGJ2mkpYkmeasTj+w1AAAAAAD9////Fz+ELsYp/55j6+Jl2unG9sGvpHTiSyzSORBvtu1GEB4AAAAAAP3///8CM8oFKgEAAAAWABSokv88M+cd6KGE2G6RPPxAR1ltkBAZBioBAAAAFgAU68J5imRcKy3g5JCT3bEoP9IXEn0AAAAAAAEBKwDyBSoBAAAAIlEgY496Q0oWz0BIuPcV2UIuKi7+N09R6AkbOUDXXUDzQwAAAQErAPIFKgEAAAAiUSCfbbX+FHJbzC71eEFLsMjDouMJbu8ogeR0eNoNxMM9CwEIQwFBeyOLUebV/YwpaLTpLIaTXaSiPS7Dn6o39X4nlUzQLfb6YyvCAsLA5GTxo+Zb0NUINZ8DaRyUWknOpU/Jzuwn2gEAAAA=").unwrap(), + change_vout: 0, + receiver_inputs: vec![ InputPair::new( TxIn { previous_output: OutPoint::from_str("1e1046edb66f1039d22c4be274a4afc1f6c6e9da65e2eb639eff29c62e843f17:0").unwrap(), @@ -858,6 +947,7 @@ mod tests { ..Default::default() }, Some(Weight::from_wu(230))) .unwrap()], + }, }; assert_eq!(p2tr_proposal.additional_input_weight(), Weight::from_wu(230)); } @@ -923,4 +1013,74 @@ mod tests { assert!(output.tap_internal_key.is_none()); } } + + // Drives `calculate_psbt_with_fee_range` along the sender-subsidy branch and + // asserts the exact sat amount debited from the sender's fee output. `context` + // (original_psbt) and `working` (payjoin_psbt) are both `Psbt`, so a swap between + // them would type-check yet silently miscompute fees. The owned vout (1) differs + // from the sender's `additionalfeeoutputindex` (0), so the contribution is NOT + // nulled and the sender subsidizes the receiver's added input weight. + #[test] + fn fee_contribution_decrements_sender_output_exactly() { + let original = original_from_test_vector(); + let sender_script = original.psbt.unsigned_tx.output[0].script_pubkey.clone(); + let sender_value_before = original.psbt.unsigned_tx.output[0].value; + let original_input_count = original.psbt.unsigned_tx.input.len(); + + // owned_vouts = [1]: the receiver owns output 1, so the fee index 0 (a sender + // output) is not owned and `additional_fee_contribution` survives sanitization. + let wants_inputs = WantsOutputs::new(original, vec![1]).commit_outputs(); + + 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 contributed_outpoint = input.txin.previous_output; + let input_weight = input.expected_weight; + let wants_fee_range = wants_inputs + .contribute_inputs([input]) + .expect("contribution should succeed") + .commit_inputs(); + + // Independently recompute the sender's subsidy from the same inputs the + // production code uses. + let (max_contribution, fee_index) = wants_fee_range + .context + .params + .additional_fee_contribution + .expect("contribution should survive sanitization"); + assert_eq!(fee_index, 0, "fee index must point at the sender output"); + let min_fee_rate = FeeRate::from_sat_per_vb_u32(10); + let effective_min = + std::cmp::max(min_fee_rate, wants_fee_range.context.params.min_fee_rate); + let additional_fee = input_weight * effective_min; + let sender_additional_fee = std::cmp::min(max_contribution, additional_fee); + assert!(sender_additional_fee > Amount::ZERO, "subsidy must be exercised"); + + let psbt = wants_fee_range + .calculate_psbt_with_fee_range( + Some(min_fee_rate), + Some(FeeRate::from_sat_per_vb_u32(1000)), + ) + .expect("fee range should be valid"); + + // The result must be built from the mutated psbt (it carries the receiver + // input); a swap that used `original_psbt` as the working psbt would drop it. + assert_eq!(psbt.unsigned_tx.input.len(), original_input_count + 1); + assert!( + psbt.unsigned_tx.input.iter().any(|txin| txin.previous_output == contributed_outpoint), + "result must contain the contributed receiver input" + ); + // The sender's fee output must be debited by exactly the computed subsidy. + let sender_out = psbt + .unsigned_tx + .output + .iter() + .find(|txo| txo.script_pubkey == sender_script) + .expect("sender output must be present"); + assert_eq!(sender_out.value, sender_value_before - sender_additional_fee); + } } diff --git a/payjoin/src/core/receive/mod.rs b/payjoin/src/core/receive/mod.rs index 259e91422..f9fdb5483 100644 --- a/payjoin/src/core/receive/mod.rs +++ b/payjoin/src/core/receive/mod.rs @@ -459,17 +459,11 @@ impl OriginalPayload { return Err(InternalPayloadError::MissingPayment.into()); } - let mut params = self.params.clone(); - if let Some((_, additional_fee_output_index)) = params.additional_fee_contribution { - // If the additional fee output index specified by the sender is pointing to a receiver output, - // the receiver should ignore the parameter. - // https://github.com/bitcoin/bips/blob/master/bip-0078.mediawiki#optional-parameters - if owned_vouts.contains(&additional_fee_output_index) { - params.additional_fee_contribution = None; - } - } - let original_payload = OriginalPayload { params, ..self.clone() }; - Ok(common::WantsOutputs::new(original_payload, owned_vouts)) + // `WantsOutputs::new` routes the raw params through `OriginalContext::new`, + // which drops `additional_fee_contribution` when it points at a receiver-owned + // output. Keeping that sanitization in one constructor ensures the live path + // and the replay reconstruction cannot diverge. + Ok(common::WantsOutputs::new(self, owned_vouts)) } } @@ -997,7 +991,7 @@ pub(crate) mod tests { }) .expect("receiver outputs should be identified"); assert_eq!(wants_outputs.owned_vouts, vec![1]); - assert_eq!(wants_outputs.params, original.params); + assert_eq!(wants_outputs.context.params, original.params); // No outputs belong to the receiver, it should error let wants_outputs = original @@ -1017,7 +1011,7 @@ pub(crate) mod tests { .identify_receiver_outputs(&mut |_| Ok(true)) .expect("receiver outputs should be identified"); assert_eq!(wants_outputs.owned_vouts, vec![0, 1]); - assert_eq!(wants_outputs.params.additional_fee_contribution, None); + assert_eq!(wants_outputs.context.params.additional_fee_contribution, None); } #[test] diff --git a/payjoin/src/core/receive/v2/mod.rs b/payjoin/src/core/receive/v2/mod.rs index 81b872b13..446a75b68 100644 --- a/payjoin/src/core/receive/v2/mod.rs +++ b/payjoin/src/core/receive/v2/mod.rs @@ -180,13 +180,11 @@ impl ReceiveSession { SessionEvent::IdentifiedReceiverOutputs(wants_outputs), ) => Ok(state.apply_identified_receiver_outputs(wants_outputs)), - (ReceiveSession::WantsOutputs(state), SessionEvent::CommittedOutputs(wants_inputs)) => - Ok(state.apply_committed_outputs(wants_inputs)), + (ReceiveSession::WantsOutputs(state), SessionEvent::CommittedOutputs(mutable)) => + Ok(state.apply_committed_outputs(mutable)), - ( - ReceiveSession::WantsInputs(state), - SessionEvent::CommittedInputs(wants_fee_range), - ) => Ok(state.apply_committed_inputs(wants_fee_range)), + (ReceiveSession::WantsInputs(state), SessionEvent::CommittedInputs(mutable)) => + Ok(state.apply_committed_inputs(mutable)), (ReceiveSession::WantsFeeRange(state), SessionEvent::AppliedFeeRange(psbt_context)) => Ok(state.apply_applied_fee_range(psbt_context)), @@ -347,19 +345,19 @@ mod sealed { impl FallbackTx for super::WantsOutputs { fn fallback_tx(&self) -> bitcoin::Transaction { - self.inner.original_psbt.clone().extract_tx_unchecked_fee_rate() + self.inner.original_psbt().clone().extract_tx_unchecked_fee_rate() } } impl FallbackTx for super::WantsInputs { fn fallback_tx(&self) -> bitcoin::Transaction { - self.inner.original_psbt.clone().extract_tx_unchecked_fee_rate() + self.inner.original_psbt().clone().extract_tx_unchecked_fee_rate() } } impl FallbackTx for super::WantsFeeRange { fn fallback_tx(&self) -> bitcoin::Transaction { - self.inner.original_psbt.clone().extract_tx_unchecked_fee_rate() + self.inner.original_psbt().clone().extract_tx_unchecked_fee_rate() } } @@ -1043,23 +1041,26 @@ impl Receiver { /// /// Outputs cannot be modified after this function is called. pub fn commit_outputs(self) -> NextStateTransition> { - let inner = self.state.inner.clone().commit_outputs(); + let inner = self.state.inner.commit_outputs(); + // Persist only the mutated remainder. `replace_receiver_outputs` shuffles + // outputs and recomputes `change_vout` via `thread_rng`, so the summary must + // be stored verbatim and copied back on replay. The session invariant + // (`original_psbt` + sanitized `params`) is re-threaded from the predecessor + // state, not re-stored in this event. NextStateTransition::success( - SessionEvent::CommittedOutputs(self.state.inner.payjoin_psbt.unsigned_tx.output), + SessionEvent::CommittedOutputs(inner.working.clone()), Receiver { state: WantsInputs { inner }, session_context: self.session_context }, ) } - pub(crate) fn apply_committed_outputs(self, outputs: Vec) -> ReceiveSession { - let mut payjoin_proposal = self.inner.payjoin_psbt.clone(); - let outputs_len = outputs.len(); - // Add the outputs that may have been replaced - payjoin_proposal.unsigned_tx.output = outputs; - payjoin_proposal.outputs = vec![Default::default(); outputs_len]; - - let mut inner = self.state.inner.commit_outputs(); - inner.payjoin_psbt = payjoin_proposal; - + pub(crate) fn apply_committed_outputs( + self, + working: common::WorkingProposal, + ) -> ReceiveSession { + // Re-thread the session invariant from the predecessor `WantsOutputs` (its + // `context` carries the sanitized original_psbt + params) and attach the + // persisted working remainder verbatim. No recomputation, no RNG. + let inner = common::WantsInputs { context: self.state.inner.context, working }; let new_state = Receiver { state: WantsInputs { inner }, session_context: self.session_context }; ReceiveSession::WantsInputs(new_state) @@ -1104,24 +1105,21 @@ impl Receiver { /// /// Inputs cannot be modified after this function is called. pub fn commit_inputs(self) -> NextStateTransition> { - let inner = self.state.inner.clone().commit_inputs(); + let inner = self.state.inner.commit_inputs(); + // Persist only the mutated remainder. `contribute_inputs` inserts inputs at + // random indices and bumps the change output, none of which is + // reconstructable from a summary, so the remainder is stored verbatim. The + // session invariant is re-threaded from the predecessor state on replay. NextStateTransition::success( - SessionEvent::CommittedInputs(inner.receiver_inputs.clone()), + SessionEvent::CommittedInputs(inner.working.clone()), Receiver { state: WantsFeeRange { inner }, session_context: self.session_context }, ) } - pub(crate) fn apply_committed_inputs( - self, - contributed_inputs: Vec, - ) -> ReceiveSession { - let inner = common::WantsFeeRange { - original_psbt: self.state.inner.original_psbt.clone(), - payjoin_psbt: self.state.inner.payjoin_psbt.clone(), - params: self.state.inner.params.clone(), - change_vout: self.state.inner.change_vout, - receiver_inputs: contributed_inputs, - }; + pub(crate) fn apply_committed_inputs(self, working: common::WorkingProposal) -> ReceiveSession { + // Re-thread the session invariant from the predecessor `WantsInputs` and + // attach the persisted working remainder verbatim. No recomputation, no RNG. + let inner = common::WantsFeeRange { context: self.state.inner.context, working }; let new_state = Receiver { state: WantsFeeRange { inner }, session_context: self.session_context }; ReceiveSession::WantsFeeRange(new_state) diff --git a/payjoin/src/core/receive/v2/session.rs b/payjoin/src/core/receive/v2/session.rs index b79431787..415f7aa9e 100644 --- a/payjoin/src/core/receive/v2/session.rs +++ b/payjoin/src/core/receive/v2/session.rs @@ -4,7 +4,7 @@ use super::{ReceiveSession, SessionContext}; use crate::error::{InternalReplayError, ReplayError}; use crate::output_substitution::OutputSubstitution; use crate::persist::{AsyncSessionPersister, SessionPersister}; -use crate::receive::{InputPair, JsonReply, OriginalPayload, PsbtContext}; +use crate::receive::{common, JsonReply, OriginalPayload, PsbtContext}; use crate::{ImplementationError, PjUri}; fn replay_events( @@ -199,8 +199,8 @@ pub enum SessionEvent { CheckedInputsNotOwned(), CheckedNoInputsSeenBefore(), IdentifiedReceiverOutputs(Vec), - CommittedOutputs(Vec), - CommittedInputs(Vec), + CommittedOutputs(common::WorkingProposal), + CommittedInputs(common::WorkingProposal), AppliedFeeRange(PsbtContext), FinalizedProposal(bitcoin::Psbt), GotReplyableError(JsonReply), @@ -227,8 +227,12 @@ pub enum SessionOutcome { #[cfg(test)] mod tests { + use std::str::FromStr; use std::time::{Duration, SystemTime}; + use bitcoin::hashes::Hash; + use bitcoin::key::rand::rngs::StdRng; + use bitcoin::key::rand::SeedableRng; use payjoin_test_utils::{BoxError, EXAMPLE_URL}; use super::*; @@ -237,9 +241,9 @@ mod tests { use crate::receive::v2::test::{mock_err, SHARED_CONTEXT}; use crate::receive::v2::{ Initialized, MaybeInputsOwned, PendingFallback, ProvisionalProposal, Receiver, - UncheckedOriginalPayload, + UncheckedOriginalPayload, WantsOutputs, }; - use crate::receive::{InternalPayloadError, PayloadError}; + use crate::receive::{InputPair, InternalPayloadError, PayloadError}; fn unchecked_receiver_from_test_vector() -> Receiver { Receiver { @@ -248,6 +252,280 @@ mod tests { } } + // Drives a fresh v2 receiver, with the given `original`, through the safety + // checks to the `WantsOutputs` typestate, persisting each step to `persister`. + // The receiver output at vout 1 in the test vector is identified as owned so a + // single-script substitution succeeds. + fn wants_outputs_with_persister( + persister: &InMemoryPersister, + original: OriginalPayload, + ) -> Receiver { + let receiver_script = original.psbt.unsigned_tx.output[1].script_pubkey.clone(); + // Seed the log with the events that precede the directly-constructed + // `UncheckedOriginalPayload` receiver so that replay can reach it. + persister + .save_event(SessionEvent::Created(SHARED_CONTEXT.clone())) + .expect("In memory persister shouldn't fail"); + persister + .save_event(SessionEvent::RetrievedOriginalPayload { + original: original.clone(), + reply_key: None, + }) + .expect("In memory persister shouldn't fail"); + let receiver = Receiver { + state: UncheckedOriginalPayload { original }, + session_context: SHARED_CONTEXT.clone(), + }; + let maybe_inputs_owned = + receiver.assume_interactive_receiver().save(persister).expect("Save should not fail"); + let maybe_inputs_seen = maybe_inputs_owned + .check_inputs_not_owned(&mut |_| Ok(false)) + .save(persister) + .expect("No inputs should be owned"); + let outputs_unknown = maybe_inputs_seen + .check_no_inputs_seen_before(&mut |_| Ok(false)) + .save(persister) + .expect("No inputs should be seen before"); + outputs_unknown + .identify_receiver_outputs(&mut |script| Ok(script == receiver_script.as_script())) + .save(persister) + .expect("Outputs should be identified") + } + + // A non-degenerate receiver output substitution: replaces the single owned + // receiver output with a fresh drain output plus extra outputs that + // `replace_receiver_outputs` interleaves at random indices. The interleave + // shuffle can move the drain output (and therefore `change_vout`) away from its + // original index, which is the lossy fact that needs to be preserved. + fn replacement_outputs() -> (Vec, bitcoin::ScriptBuf) { + let drain_script = + bitcoin::ScriptBuf::new_p2wsh(&bitcoin::WScriptHash::from_byte_array([0x11; 32])); + let outputs = vec![ + bitcoin::TxOut { + value: bitcoin::Amount::from_sat(50_000), + script_pubkey: drain_script.clone(), + }, + bitcoin::TxOut { + value: bitcoin::Amount::from_sat(10_000), + script_pubkey: bitcoin::ScriptBuf::new_p2wsh( + &bitcoin::WScriptHash::from_byte_array([0x22; 32]), + ), + }, + bitcoin::TxOut { + value: bitcoin::Amount::from_sat(10_000), + script_pubkey: bitcoin::ScriptBuf::new_p2wsh( + &bitcoin::WScriptHash::from_byte_array([0x33; 32]), + ), + }, + ]; + (outputs, drain_script) + } + + // Replaying a session resumed at `WantsInputs` must reproduce the live state, + // including the post-substitution `change_vout` and output set. The substitution + // shuffles outputs via the RNG, so the live object is the control to test against. + // Replay must copy the persisted `WorkingProposal` and the original payload state. + #[test] + fn test_replay_to_wants_inputs_matches_live() { + // `replace_receiver_outputs` shuffles the drain output to a random index, so the + // live `change_vout` only lands off `owned_vouts[0]` on some draws. The replay + // assertion below only catches a lossy reconstruction, one that pins `change_vout` + // to `owned_vouts[0]`, when the drain actually moves, where funds would be lost. + // Under the production `thread_rng` this would only happen some of the time. Seed the + // shuffle so the asserted post-substitution state is fixed and the drain always + // moves, then guard the seed with an assertion below. + const SEED: u64 = 0; + + let persister = InMemoryPersister::::default(); + let wants_outputs = wants_outputs_with_persister(&persister, original_from_test_vector()); + let owned_vout = wants_outputs.state.inner.owned_vouts[0]; + + let (outputs, drain_script) = replacement_outputs(); + let mut rng = StdRng::seed_from_u64(SEED); + let inner = wants_outputs + .state + .inner + .replace_receiver_outputs_with_rng(outputs, drain_script.as_script(), &mut rng) + .expect("Substitution should succeed"); + let live_wants_inputs = Receiver { + state: WantsOutputs { inner }, + session_context: wants_outputs.session_context, + } + .commit_outputs() + .save(&persister) + .expect("Save should not fail"); + + // If a future `rand` change stops moving the drain off `owned_vouts[0]`, a lossy + // replay would match the live state by coincidence and silently weaken this test. + // Fail loudly instead. + assert_ne!( + live_wants_inputs.state.inner.working.change_vout, owned_vout, + "seed must move the drain off owned_vouts[0]" + ); + + let (replayed, _) = replay_event_log(&persister).expect("replay should succeed"); + let replayed = match replayed { + ReceiveSession::WantsInputs(r) => r, + other => panic!("Expected WantsInputs, got {other:?}"), + }; + assert_eq!(replayed, live_wants_inputs, "replayed WantsInputs must equal the live state"); + } + + // Replaying a session resumed at `WantsFeeRange` must reproduce the live state, + // including the contributed inputs and the change increment that the RNG-driven + // `contribute_inputs` produced. + #[test] + fn test_replay_to_wants_fee_range_matches_live() { + let persister = InMemoryPersister::::default(); + let wants_outputs = wants_outputs_with_persister(&persister, original_from_test_vector()); + + let (outputs, drain_script) = replacement_outputs(); + let wants_inputs = wants_outputs + .replace_receiver_outputs(outputs, drain_script.as_script()) + .expect("Substitution should succeed") + .commit_outputs() + .save(&persister) + .expect("Save should not fail"); + + let proposal_psbt = + bitcoin::Psbt::from_str(payjoin_test_utils::RECEIVER_INPUT_CONTRIBUTION) + .expect("valid proposal psbt"); + let input = InputPair::new( + proposal_psbt.unsigned_tx.input[1].clone(), + proposal_psbt.inputs[1].clone(), + None, + ) + .expect("valid input pair"); + let live_wants_fee_range = wants_inputs + .contribute_inputs([input]) + .expect("Contribution should succeed") + .commit_inputs() + .save(&persister) + .expect("Save should not fail"); + + let (replayed, _) = replay_event_log(&persister).expect("replay should succeed"); + let replayed = match replayed { + ReceiveSession::WantsFeeRange(r) => r, + other => panic!("Expected WantsFeeRange, got {other:?}"), + }; + assert_eq!( + replayed, live_wants_fee_range, + "replayed WantsFeeRange must equal the live state" + ); + } + + // Provenance: the sender points `additional_fee_contribution` at vout 1, which + // the receiver owns. Per BIP78 the receiver must ignore it. The nulling lives in + // `OriginalContext::new`, which both the live `identify_receiver_outputs` and the + // replay reconstruction route through. Resuming exactly at `WantsOutputs` proves + // replay applies the same sanitization as live. If `apply_identified_receiver_outputs` + // reconstructed from raw params instead, the replayed contribution would be + // `Some(..)` and this test would fail. + #[test] + fn test_replay_to_wants_outputs_nulls_owned_fee_contribution() { + let persister = InMemoryPersister::::default(); + let mut original = original_from_test_vector(); + // Point the fee contribution at the receiver-owned output (vout 1). + original.params.additional_fee_contribution = Some((bitcoin::Amount::from_sat(182), 1)); + + let live_wants_outputs = wants_outputs_with_persister(&persister, original); + assert_eq!( + live_wants_outputs.state.inner.context.params.additional_fee_contribution, None, + "live identify must drop a fee contribution pointed at an owned output" + ); + + let (replayed, _) = replay_event_log(&persister).expect("replay should succeed"); + let replayed = match replayed { + ReceiveSession::WantsOutputs(r) => r, + other => panic!("Expected WantsOutputs, got {other:?}"), + }; + assert_eq!( + replayed.state.inner.context.params.additional_fee_contribution, None, + "replay must apply the same owned-vout nulling as live" + ); + assert_eq!(replayed, live_wants_outputs, "replayed WantsOutputs must equal the live state"); + } + + // Forward-read compatibility: an event log written by an earlier format that stored + // the full receiver state in each commit event (the working proposal plus the + // redundant original PSBT and params) must still deserialize and replay under this + // leaner format, which stores only the working proposal. The lean payload is a strict + // subset of the older one, and the externally-tagged enum ignores unknown fields, so + // the dropped invariant is discarded on read and re-threaded from the predecessor + // state. The log replays to the identical state, so a JSON persister needs no + // migration from the older format. + #[test] + fn replays_legacy_full_struct_commit_events() { + // Produce a log in the current lean format (with a substitution + input contribution). + let persister = InMemoryPersister::::default(); + let wants_outputs = wants_outputs_with_persister(&persister, original_from_test_vector()); + let (outputs, drain_script) = replacement_outputs(); + let wants_inputs = wants_outputs + .replace_receiver_outputs(outputs, drain_script.as_script()) + .expect("Substitution should succeed") + .commit_outputs() + .save(&persister) + .expect("Save should not fail"); + let proposal_psbt = + bitcoin::Psbt::from_str(payjoin_test_utils::RECEIVER_INPUT_CONTRIBUTION) + .expect("valid proposal psbt"); + let input = InputPair::new( + proposal_psbt.unsigned_tx.input[1].clone(), + proposal_psbt.inputs[1].clone(), + None, + ) + .expect("valid input pair"); + let live_wants_fee_range = wants_inputs + .contribute_inputs([input]) + .expect("Contribution should succeed") + .commit_inputs() + .save(&persister) + .expect("Save should not fail"); + + // The redundant invariant an older-format log would have stored alongside each + // commit event. It is identical for both commit events. + let context_value = + serde_json::to_value(&live_wants_fee_range.state.inner.context).expect("serialize"); + let original_psbt_value = context_value.get("original_psbt").expect("psbt").clone(); + let params_value = context_value.get("params").expect("params").clone(); + + // Rewrite the lean log into an older-format JSON log: splice `original_psbt`/`params` + // back into the two commit events (the only events whose payload differs + // across the format change), simulating a log written before the slim. Other events + // are identical across formats and pass through unchanged. + let c_events = persister.inner.lock().expect("lock").events.clone(); + let legacy_persister = InMemoryPersister::::default(); + for event in c_events { + let legacy_event = match event { + SessionEvent::CommittedOutputs(_) | SessionEvent::CommittedInputs(_) => { + let mut value = serde_json::to_value(&event).expect("serialize event"); + let inner = value + .as_object_mut() + .and_then(|obj| obj.values_mut().next()) + .and_then(|i| i.as_object_mut()) + .expect("commit event payload is an object"); + inner.insert("original_psbt".to_string(), original_psbt_value.clone()); + inner.insert("params".to_string(), params_value.clone()); + serde_json::from_value(value) + .expect("older-format commit event must deserialize under the lean format") + } + other => other, + }; + legacy_persister.save_event(legacy_event).expect("save should not fail"); + } + + let (replayed, _) = replay_event_log(&legacy_persister) + .expect("older-format log must replay under the lean format"); + let replayed = match replayed { + ReceiveSession::WantsFeeRange(r) => r, + other => panic!("Expected WantsFeeRange, got {other:?}"), + }; + assert_eq!( + replayed, live_wants_fee_range, + "an older-format log must replay to the identical lean state" + ); + } + #[test] fn test_session_event_serialization_roundtrip() { let persister = InMemoryPersister::::default(); @@ -300,10 +578,8 @@ mod tests { SessionEvent::CheckedInputsNotOwned(), SessionEvent::CheckedNoInputsSeenBefore(), SessionEvent::IdentifiedReceiverOutputs(wants_outputs.state.inner.owned_vouts.clone()), - SessionEvent::CommittedOutputs( - wants_outputs.state.inner.payjoin_psbt.unsigned_tx.output, - ), - SessionEvent::CommittedInputs(wants_fee_range.state.inner.receiver_inputs.clone()), + SessionEvent::CommittedOutputs(wants_inputs.state.inner.working.clone()), + SessionEvent::CommittedInputs(wants_fee_range.state.inner.working.clone()), SessionEvent::AppliedFeeRange(provisional_proposal.state.psbt_context.clone()), SessionEvent::FinalizedProposal(payjoin_proposal.psbt().clone()), SessionEvent::GotReplyableError(mock_err()), @@ -739,12 +1015,8 @@ mod tests { events.push(SessionEvent::IdentifiedReceiverOutputs( wants_outputs.state.inner.owned_vouts.clone(), )); - events.push(SessionEvent::CommittedOutputs( - wants_outputs.state.inner.payjoin_psbt.unsigned_tx.output, - )); - events.push(SessionEvent::CommittedInputs( - wants_fee_range.state.inner.receiver_inputs.clone(), - )); + events.push(SessionEvent::CommittedOutputs(wants_inputs.state.inner.working.clone())); + events.push(SessionEvent::CommittedInputs(wants_fee_range.state.inner.working.clone())); events.push(SessionEvent::AppliedFeeRange(provisional_proposal.state.psbt_context.clone())); let test = SessionHistoryTest { @@ -818,12 +1090,8 @@ mod tests { events.push(SessionEvent::IdentifiedReceiverOutputs( wants_outputs.state.inner.owned_vouts.clone(), )); - events.push(SessionEvent::CommittedOutputs( - wants_outputs.state.inner.payjoin_psbt.unsigned_tx.output, - )); - events.push(SessionEvent::CommittedInputs( - wants_fee_range.state.inner.receiver_inputs.clone(), - )); + events.push(SessionEvent::CommittedOutputs(wants_inputs.state.inner.working.clone())); + events.push(SessionEvent::CommittedInputs(wants_fee_range.state.inner.working.clone())); events.push(SessionEvent::AppliedFeeRange(provisional_proposal.state.psbt_context.clone())); events.push(SessionEvent::FinalizedProposal(payjoin_proposal.psbt().clone())); events.push(SessionEvent::Closed(SessionOutcome::Success(vec![])));