From 1d1e3786c716aa5985ef4928e0b4c734efa2881c Mon Sep 17 00:00:00 2001 From: DanGould Date: Tue, 26 Aug 2025 15:05:30 -0400 Subject: [PATCH 01/22] Move FinalizeProposalError to error mod --- payjoin/src/core/receive/error.rs | 24 ++++++++++++++++++++++++ payjoin/src/core/receive/mod.rs | 24 +----------------------- 2 files changed, 25 insertions(+), 23 deletions(-) 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..480c313a3 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}; @@ -337,27 +336,6 @@ 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, From 34e6ab1f6354e19c3d2f02269446a09ce29ad03c Mon Sep 17 00:00:00 2001 From: DanGould Date: Tue, 26 Aug 2025 15:20:03 -0400 Subject: [PATCH 02/22] Move v1-exclusive code to appropriate module Code that was only for v1 and NOT v2 was in the common folder. --- payjoin/src/core/receive/v1/exclusive/mod.rs | 709 ++++++++++++++++++ payjoin/src/core/receive/v1/mod.rs | 719 +------------------ 2 files changed, 710 insertions(+), 718 deletions(-) diff --git a/payjoin/src/core/receive/v1/exclusive/mod.rs b/payjoin/src/core/receive/v1/exclusive/mod.rs index 0eab9d874..708d481df 100644 --- a/payjoin/src/core/receive/v1/exclusive/mod.rs +++ b/payjoin/src/core/receive/v1/exclusive/mod.rs @@ -1,4 +1,5 @@ mod error; +use bitcoin::OutPoint; pub(crate) use error::InternalRequestError; pub use error::RequestError; @@ -40,6 +41,152 @@ impl UncheckedProposal { } } +/// The original PSBT and the optional parameters received from the sender. +/// +/// This is the first typestate after the retrieval of the sender's original proposal in +/// the receiver's workflow. At this stage, the receiver can verify that the original PSBT they have +/// received from the sender is broadcastable to the network in the case of a payjoin failure. +/// +/// The recommended usage of this typestate differs based on whether you are implementing an +/// interactive (where the receiver takes manual actions to respond to the +/// payjoin proposal) or a non-interactive (ex. a donation page which automatically generates a new QR code +/// for each visit) payment receiver. For the latter, you should call [`Self::check_broadcast_suitability`] to check +/// that the proposal is actually broadcastable (and, optionally, whether the fee rate is above the +/// minimum limit you have set). These mechanisms protect the receiver against probing attacks, where +/// a malicious sender can repeatedly send proposals to have the non-interactive receiver reveal the UTXOs +/// it owns with the proposals it modifies. +/// +/// 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. +#[derive(Debug, Clone)] +pub struct UncheckedProposal { + original: Original, +} + +impl UncheckedProposal { + /// Checks that the original PSBT in the proposal can be broadcasted. + /// + /// If the receiver is a non-interactive payment processor (ex. a donation page which generates + /// a new QR code for each visit), then it should make sure that the original PSBT is broadcastable + /// as a fallback mechanism in case the payjoin fails. This validation would be equivalent to + /// `testmempoolaccept` Bitcoin Core RPC call returning `{"allowed": true,...}`. + /// + /// Receiver can optionally set a minimum fee rate which will be enforced on the original PSBT in the proposal. + /// 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. + pub fn check_broadcast_suitability( + self, + min_fee_rate: Option, + can_broadcast: impl Fn(&bitcoin::Transaction) -> Result, + ) -> Result { + self.original.check_broadcast_suitability(min_fee_rate, can_broadcast)?; + Ok(MaybeInputsOwned { original: self.original }) + } + + /// Moves on to the next typestate without any of the current typestate's validations. + /// + /// Use this for interactive payment receivers, where there is no risk of a probing attack since the + /// receiver needs to manually create payjoin URIs. + pub fn assume_interactive_receiver(self) -> MaybeInputsOwned { + MaybeInputsOwned { original: self.original } + } +} + +/// Typestate to check that the original PSBT has no inputs owned by the receiver. +/// +/// At this point, it has been verified that the transaction is broadcastable from previous +/// typestate. The receiver can call [`Self::extract_tx_to_schedule_broadcast`] +/// to extract the signed original PSBT to schedule a fallback in case the Payjoin process fails. +/// +/// Call [`Self::check_inputs_not_owned`] to proceed. +#[derive(Debug, Clone)] +pub struct MaybeInputsOwned { + pub(crate) original: Original, +} + +impl MaybeInputsOwned { + /// Extracts the original transaction received from the sender. + /// + /// 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. + pub fn extract_tx_to_schedule_broadcast(&self) -> bitcoin::Transaction { + self.original.psbt.clone().extract_tx_unchecked_fee_rate() + } + + /// 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. + pub fn check_inputs_not_owned( + self, + is_owned: &mut impl FnMut(&Script) -> Result, + ) -> Result { + self.original.check_inputs_not_owned(is_owned)?; + Ok(MaybeInputsSeen { original: self.original }) + } +} + +/// Typestate to check that the original PSBT has no inputs that the receiver has seen before. +/// +/// Call [`Self::check_no_inputs_seen_before`] to proceed. +#[derive(Debug, Clone)] +pub struct MaybeInputsSeen { + original: Original, +} +impl MaybeInputsSeen { + /// Check that the receiver has never seen the inputs in the original proposal before. + /// + /// This check prevents the following attacks: + /// 1. Probing attacks, where the sender can use the exact same proposal (or with minimal change) + /// to have the receiver reveal their UTXO set by contributing to all proposals with different inputs + /// 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. + pub fn check_no_inputs_seen_before( + self, + is_known: &mut impl FnMut(&OutPoint) -> Result, + ) -> Result { + self.original.check_no_inputs_seen_before(is_known)?; + Ok(OutputsUnknown { original: self.original }) + } +} + +/// Typestate to check that the outputs of the original PSBT actually pay to the receiver. +/// +/// The receiver should only accept the original PSBTs from the sender if it actually sends them +/// money. +/// +/// Call [`Self::identify_receiver_outputs`] to proceed. +#[derive(Debug, Clone)] +pub struct OutputsUnknown { + original: Original, +} + +impl OutputsUnknown { + /// Validates whether the original PSBT contains outputs which pay to the receiver and only + /// then proceeds to the next typestate. + /// + /// Additionally, this function also protects the receiver from accidentally subtracting fees + /// from their own outputs: when a sender is sending a proposal, + /// they can select an output which they want the receiver to subtract fees from to account for + /// the increased transaction size. If a sender specifies a receiver output for this purpose, this + /// function sets that parameter to None so that it is ignored in subsequent steps of the + /// receiver flow. This protects the receiver from accidentally subtracting fees from their own + /// outputs. + #[cfg_attr(not(feature = "v1"), allow(dead_code))] + pub fn identify_receiver_outputs( + self, + is_receiver_output: &mut impl FnMut(&Script) -> Result, + ) -> Result { + let owned_vouts = self.original.identify_receiver_outputs(is_receiver_output)?; + // 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)) + } +} + /// Validate the request headers for a Payjoin request /// /// [`RequestError`] should only be produced here. @@ -141,3 +288,565 @@ mod tests { Ok(()) } } + +#[cfg(test)] +pub(crate) mod test { + 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 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 } + } + + pub(crate) 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 { + 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"); + MaybeInputsOwned { original: Original { psbt: PARSED_ORIGINAL_PSBT.clone(), params } } + } + + fn wants_outputs_from_test_vector(proposal: UncheckedProposal) -> WantsOutputs { + 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; + Ok(Address::from_script(script, network).unwrap() + == Address::from_str("3CZZi7aWFugaCdUCS15dgrUUViupmB8bVM") + .unwrap() + .require_network(network) + .unwrap()) + }) + .expect("Receiver output should be identified") + } + + fn provisional_proposal_from_test_vector(proposal: UncheckedProposal) -> ProvisionalProposal { + wants_outputs_from_test_vector(proposal) + .commit_outputs() + .commit_inputs() + .apply_fee_range(None, None) + .expect("Contributed inputs should allow for valid fee contributions") + } + + #[test] + fn test_mutable_receiver_state_closures() { + let mut call_count = 0; + let maybe_inputs_owned = maybe_inputs_owned_from_test_vector(); + + fn mock_callback(call_count: &mut usize, ret: bool) -> Result { + *call_count += 1; + Ok(ret) + } + + let maybe_inputs_seen = maybe_inputs_owned + .check_inputs_not_owned(&mut |_| mock_callback(&mut call_count, false)); + assert_eq!(call_count, 1); + + let outputs_unknown = maybe_inputs_seen + .map_err(|_| "Check inputs owned closure failed".to_string()) + .expect("Next receiver state should be accessible") + .check_no_inputs_seen_before(&mut |_| mock_callback(&mut call_count, false)); + assert_eq!(call_count, 2); + + let _wants_outputs = outputs_unknown + .map_err(|_| "Check no inputs seen closure failed".to_string()) + .expect("Next receiver state should be accessible") + .identify_receiver_outputs(&mut |_| mock_callback(&mut call_count, true)); + // there are 2 receiver outputs so we should expect this callback to run twice incrementing + // call count twice + assert_eq!(call_count, 4); + } + + #[test] + fn is_output_substitution_disabled() { + let mut proposal = unchecked_proposal_from_test_vector(); + let payjoin = wants_outputs_from_test_vector(proposal.clone()); + assert_eq!(payjoin.output_substitution(), OutputSubstitution::Enabled); + + proposal.original.params.output_substitution = OutputSubstitution::Disabled; + let payjoin = wants_outputs_from_test_vector(proposal); + assert_eq!(payjoin.output_substitution(), OutputSubstitution::Disabled); + } + + #[test] + fn unchecked_proposal_min_fee() { + let proposal = unchecked_proposal_from_test_vector(); + + let min_fee_rate = + proposal.original.psbt_fee_rate().expect("Feerate calculation should not fail"); + let _ = proposal + .clone() + .check_broadcast_suitability(Some(min_fee_rate), |_| Ok(true)) + .expect("Broadcast suitability check with appropriate min_fee_rate should succeed"); + assert_eq!(proposal.original.psbt_fee_rate().unwrap(), min_fee_rate); + + let min_fee_rate = FeeRate::MAX; + let expected_err = + ReplyableError::Payload(PayloadError(InternalPayloadError::PsbtBelowFeeRate( + proposal.original.psbt_fee_rate().unwrap(), + min_fee_rate, + ))); + let proposal_below_min_fee = proposal + .check_broadcast_suitability(Some(min_fee_rate), |_| Ok(true)) + .expect_err("Broadcast suitability with min_fee_rate below minimum should fail"); + 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(); + let provisional = provisional_proposal_from_test_vector(proposal); + let empty_tx = Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: LockTime::Seconds(Time::MIN), + input: vec![], + output: vec![], + }; + let other_psbt = Psbt::from_unsigned_tx(empty_tx).expect("Valid unsigned tx"); + let err = provisional.clone().finalize_proposal(|_| Ok(other_psbt.clone())).unwrap_err(); + assert_eq!( + err.to_string(), + format!( + "Internal Server Error: Ntxid mismatch: expected {}, got {}", + provisional.psbt_context.payjoin_psbt.unsigned_tx.compute_txid(), + other_psbt.unsigned_tx.compute_txid() + ) + ); + } +} diff --git a/payjoin/src/core/receive/v1/mod.rs b/payjoin/src/core/receive/v1/mod.rs index 0b952cc1b..99e59ccc3 100644 --- a/payjoin/src/core/receive/v1/mod.rs +++ b/payjoin/src/core/receive/v1/mod.rs @@ -36,7 +36,7 @@ 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, OutPoint, Script, TxIn, TxOut, Weight}; +use bitcoin::{Amount, FeeRate, Script, TxIn, TxOut, Weight}; use serde::{Deserialize, Serialize}; use super::error::{ @@ -55,161 +55,6 @@ mod exclusive; #[cfg(feature = "v1")] pub use exclusive::*; -/// The original PSBT and the optional parameters received from the sender. -/// -/// This is the first typestate after the retrieval of the sender's original proposal in -/// the receiver's workflow. At this stage, the receiver can verify that the original PSBT they have -/// received from the sender is broadcastable to the network in the case of a payjoin failure. -/// -/// The recommended usage of this typestate differs based on whether you are implementing an -/// interactive (where the receiver takes manual actions to respond to the -/// payjoin proposal) or a non-interactive (ex. a donation page which automatically generates a new QR code -/// for each visit) payment receiver. For the latter, you should call [`Self::check_broadcast_suitability`] to check -/// that the proposal is actually broadcastable (and, optionally, whether the fee rate is above the -/// minimum limit you have set). These mechanisms protect the receiver against probing attacks, where -/// a malicious sender can repeatedly send proposals to have the non-interactive receiver reveal the UTXOs -/// it owns with the proposals it modifies. -/// -/// 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, -} - -impl UncheckedProposal { - /// Checks that the original PSBT in the proposal can be broadcasted. - /// - /// If the receiver is a non-interactive payment processor (ex. a donation page which generates - /// a new QR code for each visit), then it should make sure that the original PSBT is broadcastable - /// as a fallback mechanism in case the payjoin fails. This validation would be equivalent to - /// `testmempoolaccept` Bitcoin Core RPC call returning `{"allowed": true,...}`. - /// - /// Receiver can optionally set a minimum fee rate which will be enforced on the original PSBT in the proposal. - /// 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, - can_broadcast: impl Fn(&bitcoin::Transaction) -> Result, - ) -> Result { - self.original.check_broadcast_suitability(min_fee_rate, can_broadcast)?; - Ok(MaybeInputsOwned { original: self.original }) - } - - /// Moves on to the next typestate without any of the current typestate's validations. - /// - /// 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 } - } -} - -/// Typestate to check that the original PSBT has no inputs owned by the receiver. -/// -/// At this point, it has been verified that the transaction is broadcastable from previous -/// typestate. The receiver can call [`Self::extract_tx_to_schedule_broadcast`] -/// to extract the signed original PSBT to schedule a fallback in case the Payjoin process fails. -/// -/// 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, -} - -impl MaybeInputsOwned { - /// Extracts the original transaction received from the sender. - /// - /// 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() - } - - /// 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, - ) -> Result { - self.original.check_inputs_not_owned(is_owned)?; - Ok(MaybeInputsSeen { original: self.original }) - } -} - -/// Typestate to check that the original PSBT has no inputs that the receiver has seen before. -/// -/// 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, -} -impl MaybeInputsSeen { - /// Check that the receiver has never seen the inputs in the original proposal before. - /// - /// This check prevents the following attacks: - /// 1. Probing attacks, where the sender can use the exact same proposal (or with minimal change) - /// to have the receiver reveal their UTXO set by contributing to all proposals with different inputs - /// 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, - ) -> Result { - self.original.check_no_inputs_seen_before(is_known)?; - Ok(OutputsUnknown { original: self.original }) - } -} - -/// Typestate to check that the outputs of the original PSBT actually pay to the receiver. -/// -/// The receiver should only accept the original PSBTs from the sender if it actually sends them -/// money. -/// -/// Call [`Self::identify_receiver_outputs`] to proceed. -#[derive(Debug, Clone)] -#[cfg_attr(not(feature = "v1"), allow(dead_code))] -pub struct OutputsUnknown { - original: Original, -} - -impl OutputsUnknown { - /// Validates whether the original PSBT contains outputs which pay to the receiver and only - /// then proceeds to the next typestate. - /// - /// Additionally, this function also protects the receiver from accidentally subtracting fees - /// from their own outputs: when a sender is sending a proposal, - /// they can select an output which they want the receiver to subtract fees from to account for - /// the increased transaction size. If a sender specifies a receiver output for this purpose, this - /// function sets that parameter to None so that it is ignored in subsequent steps of the - /// receiver flow. This protects the receiver from accidentally subtracting fees from their own - /// outputs. - #[cfg_attr(not(feature = "v1"), allow(dead_code))] - pub fn identify_receiver_outputs( - self, - is_receiver_output: &mut impl FnMut(&Script) -> Result, - ) -> Result { - let owned_vouts = self.original.identify_receiver_outputs(is_receiver_output)?; - // 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 @@ -728,565 +573,3 @@ impl PayjoinProposal { /// The Payjoin Proposal PSBT. pub fn psbt(&self) -> &Psbt { &self.payjoin_psbt } } - -#[cfg(test)] -pub(crate) mod test { - 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 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 } - } - - pub(crate) 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 { - 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"); - MaybeInputsOwned { original: Original { psbt: PARSED_ORIGINAL_PSBT.clone(), params } } - } - - fn wants_outputs_from_test_vector(proposal: UncheckedProposal) -> WantsOutputs { - 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; - Ok(Address::from_script(script, network).unwrap() - == Address::from_str("3CZZi7aWFugaCdUCS15dgrUUViupmB8bVM") - .unwrap() - .require_network(network) - .unwrap()) - }) - .expect("Receiver output should be identified") - } - - fn provisional_proposal_from_test_vector(proposal: UncheckedProposal) -> ProvisionalProposal { - wants_outputs_from_test_vector(proposal) - .commit_outputs() - .commit_inputs() - .apply_fee_range(None, None) - .expect("Contributed inputs should allow for valid fee contributions") - } - - #[test] - fn test_mutable_receiver_state_closures() { - let mut call_count = 0; - let maybe_inputs_owned = maybe_inputs_owned_from_test_vector(); - - fn mock_callback(call_count: &mut usize, ret: bool) -> Result { - *call_count += 1; - Ok(ret) - } - - let maybe_inputs_seen = maybe_inputs_owned - .check_inputs_not_owned(&mut |_| mock_callback(&mut call_count, false)); - assert_eq!(call_count, 1); - - let outputs_unknown = maybe_inputs_seen - .map_err(|_| "Check inputs owned closure failed".to_string()) - .expect("Next receiver state should be accessible") - .check_no_inputs_seen_before(&mut |_| mock_callback(&mut call_count, false)); - assert_eq!(call_count, 2); - - let _wants_outputs = outputs_unknown - .map_err(|_| "Check no inputs seen closure failed".to_string()) - .expect("Next receiver state should be accessible") - .identify_receiver_outputs(&mut |_| mock_callback(&mut call_count, true)); - // there are 2 receiver outputs so we should expect this callback to run twice incrementing - // call count twice - assert_eq!(call_count, 4); - } - - #[test] - fn is_output_substitution_disabled() { - let mut proposal = unchecked_proposal_from_test_vector(); - let payjoin = wants_outputs_from_test_vector(proposal.clone()); - assert_eq!(payjoin.output_substitution(), OutputSubstitution::Enabled); - - proposal.original.params.output_substitution = OutputSubstitution::Disabled; - let payjoin = wants_outputs_from_test_vector(proposal); - assert_eq!(payjoin.output_substitution(), OutputSubstitution::Disabled); - } - - #[test] - fn unchecked_proposal_min_fee() { - let proposal = unchecked_proposal_from_test_vector(); - - let min_fee_rate = - proposal.original.psbt_fee_rate().expect("Feerate calculation should not fail"); - let _ = proposal - .clone() - .check_broadcast_suitability(Some(min_fee_rate), |_| Ok(true)) - .expect("Broadcast suitability check with appropriate min_fee_rate should succeed"); - assert_eq!(proposal.original.psbt_fee_rate().unwrap(), min_fee_rate); - - let min_fee_rate = FeeRate::MAX; - let expected_err = - ReplyableError::Payload(PayloadError(InternalPayloadError::PsbtBelowFeeRate( - proposal.original.psbt_fee_rate().unwrap(), - min_fee_rate, - ))); - let proposal_below_min_fee = proposal - .check_broadcast_suitability(Some(min_fee_rate), |_| Ok(true)) - .expect_err("Broadcast suitability with min_fee_rate below minimum should fail"); - 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(); - let provisional = provisional_proposal_from_test_vector(proposal); - let empty_tx = Transaction { - version: bitcoin::transaction::Version::TWO, - lock_time: LockTime::Seconds(Time::MIN), - input: vec![], - output: vec![], - }; - let other_psbt = Psbt::from_unsigned_tx(empty_tx).expect("Valid unsigned tx"); - let err = provisional.clone().finalize_proposal(|_| Ok(other_psbt.clone())).unwrap_err(); - assert_eq!( - err.to_string(), - format!( - "Internal Server Error: Ntxid mismatch: expected {}, got {}", - provisional.psbt_context.payjoin_psbt.unsigned_tx.compute_txid(), - other_psbt.unsigned_tx.compute_txid() - ) - ); - } -} From 3a1fa44557a438c6de0c14e452bf803904d0f84f Mon Sep 17 00:00:00 2001 From: DanGould Date: Tue, 26 Aug 2025 15:25:29 -0400 Subject: [PATCH 03/22] Move receive::v1 to receive::common The `exclusive` name does not make sense in this context since it's really the `v1` module now. That change comes next. --- payjoin-cli/src/app/v1.rs | 10 +-- .../receive/{v1 => common}/exclusive/error.rs | 0 .../receive/{v1 => common}/exclusive/mod.rs | 0 .../src/core/receive/{v1 => common}/mod.rs | 0 payjoin/src/core/receive/error.rs | 2 +- payjoin/src/core/receive/mod.rs | 4 +- payjoin/src/core/receive/v2/mod.rs | 78 +++++++++---------- payjoin/src/core/receive/v2/session.rs | 14 ++-- payjoin/tests/integration.rs | 12 +-- 9 files changed, 56 insertions(+), 64 deletions(-) rename payjoin/src/core/receive/{v1 => common}/exclusive/error.rs (100%) rename payjoin/src/core/receive/{v1 => common}/exclusive/mod.rs (100%) rename payjoin/src/core/receive/{v1 => common}/mod.rs (100%) diff --git a/payjoin-cli/src/app/v1.rs b/payjoin-cli/src/app/v1.rs index d9ea076b2..57473771a 100644 --- a/payjoin-cli/src/app/v1.rs +++ b/payjoin-cli/src/app/v1.rs @@ -13,7 +13,7 @@ use hyper::{Method, Request, Response, StatusCode}; use hyper_util::rt::TokioIo; use payjoin::bitcoin::psbt::Psbt; use payjoin::bitcoin::{Amount, FeeRate}; -use payjoin::receive::v1::{PayjoinProposal, UncheckedProposal}; +use payjoin::receive::common::{PayjoinProposal, UncheckedProposal}; use payjoin::receive::ReplyableError::{self, Implementation, V1}; use payjoin::send::v1::SenderBuilder; use payjoin::{ImplementationError, IntoUrl, Uri, UriExt}; @@ -27,7 +27,7 @@ use crate::app::{handle_interrupt, http_agent}; use crate::db::Database; struct Headers<'a>(&'a hyper::HeaderMap); -impl payjoin::receive::v1::Headers for Headers<'_> { +impl payjoin::receive::common::Headers for Headers<'_> { fn get_header(&self, key: &str) -> Option<&str> { self.0.get(key).map(|v| v.to_str()).transpose().ok().flatten() } @@ -117,7 +117,7 @@ impl App { fn construct_payjoin_uri(&self, amount: Amount, endpoint: impl IntoUrl) -> Result { let pj_receiver_address = self.wallet.get_new_address()?; - let mut pj_uri = payjoin::receive::v1::build_v1_pj_uri( + let mut pj_uri = payjoin::receive::common::build_v1_pj_uri( &pj_receiver_address, endpoint, payjoin::OutputSubstitution::Enabled, @@ -374,9 +374,9 @@ impl App { } fn try_contributing_inputs( - payjoin: payjoin::receive::v1::WantsInputs, + payjoin: payjoin::receive::common::WantsInputs, wallet: &BitcoindWallet, -) -> Result { +) -> Result { let candidate_inputs = wallet.list_unspent().map_err(|e| ImplementationError::from(e.into_boxed_dyn_error()))?; diff --git a/payjoin/src/core/receive/v1/exclusive/error.rs b/payjoin/src/core/receive/common/exclusive/error.rs similarity index 100% rename from payjoin/src/core/receive/v1/exclusive/error.rs rename to payjoin/src/core/receive/common/exclusive/error.rs diff --git a/payjoin/src/core/receive/v1/exclusive/mod.rs b/payjoin/src/core/receive/common/exclusive/mod.rs similarity index 100% rename from payjoin/src/core/receive/v1/exclusive/mod.rs rename to payjoin/src/core/receive/common/exclusive/mod.rs diff --git a/payjoin/src/core/receive/v1/mod.rs b/payjoin/src/core/receive/common/mod.rs similarity index 100% rename from payjoin/src/core/receive/v1/mod.rs rename to payjoin/src/core/receive/common/mod.rs diff --git a/payjoin/src/core/receive/error.rs b/payjoin/src/core/receive/error.rs index b711e0dfd..ee345cff8 100644 --- a/payjoin/src/core/receive/error.rs +++ b/payjoin/src/core/receive/error.rs @@ -57,7 +57,7 @@ pub enum ReplyableError { Payload(PayloadError), /// Protocol-specific errors for BIP-78 v1 requests (e.g. HTTP request validation, parameter checks) #[cfg(feature = "v1")] - V1(crate::receive::v1::RequestError), + V1(crate::receive::common::RequestError), /// Error arising due to the specific receiver implementation /// /// e.g. database errors, network failures, wallet errors diff --git a/payjoin/src/core/receive/mod.rs b/payjoin/src/core/receive/mod.rs index 480c313a3..b54164509 100644 --- a/payjoin/src/core/receive/mod.rs +++ b/payjoin/src/core/receive/mod.rs @@ -35,9 +35,9 @@ pub(crate) mod optional_parameters; #[cfg(feature = "v1")] #[cfg_attr(docsrs, doc(cfg(feature = "v1")))] -pub mod v1; +pub mod common; #[cfg(not(feature = "v1"))] -pub(crate) mod v1; +pub(crate) mod common; #[cfg(feature = "v2")] #[cfg_attr(docsrs, doc(cfg(feature = "v2")))] diff --git a/payjoin/src/core/receive/v2/mod.rs b/payjoin/src/core/receive/v2/mod.rs index 79c5932f7..3500b63af 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::{ @@ -705,28 +706,23 @@ impl Receiver { } }, }; - let wants_outputs = v1::WantsOutputs::from_proposal(self.state.original, owned_vouts); + let inner = common::WantsOutputs::from_proposal(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,7 @@ 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 inner = match self.state.inner.apply_fee_range(min_fee_rate, max_effective_fee_rate) { Ok(inner) => inner, Err(e) => { return MaybeFatalTransition::fatal( @@ -912,10 +901,13 @@ impl Receiver { ) } - pub(crate) fn apply_provisional_proposal(self, v1: v1::ProvisionalProposal) -> ReceiveSession { + pub(crate) fn apply_provisional_proposal( + self, + inner: common::ProvisionalProposal, + ) -> ReceiveSession { let new_state = Receiver { state: ProvisionalProposal { - psbt_context: v1.psbt_context, + psbt_context: inner.psbt_context, session_context: self.state.session_context, }, }; @@ -1322,7 +1314,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::common::test::proposal_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..753231351 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}; use crate::{ImplementationError, IntoUrl, PjUri, Request}; /// Errors that can occur when replaying a receiver event log @@ -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(common::ProvisionalProposal), 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,7 +178,7 @@ mod tests { use super::*; use crate::persist::test_utils::InMemoryTestPersister; - use crate::receive::v1::test::{ + use crate::receive::common::test::{ proposal_from_test_vector, unchecked_proposal_from_test_vector, }; use crate::receive::v2::test::SHARED_CONTEXT; @@ -188,7 +188,7 @@ mod tests { #[test] fn test_session_event_serialization_roundtrip() { - let proposal = crate::receive::v1::test::proposal_from_test_vector(); + let proposal = crate::receive::common::test::proposal_from_test_vector(); let unchecked_proposal = unchecked_proposal_from_test_vector(); let maybe_inputs_owned = unchecked_proposal.clone().assume_interactive_receiver(); let maybe_inputs_seen = maybe_inputs_owned diff --git a/payjoin/tests/integration.rs b/payjoin/tests/integration.rs index ecc36fd50..b6747e69b 100644 --- a/payjoin/tests/integration.rs +++ b/payjoin/tests/integration.rs @@ -9,7 +9,7 @@ mod integration { use bitcoin::{Amount, FeeRate, OutPoint, TxIn, TxOut, Weight}; use bitcoind::bitcoincore_rpc::json::{AddressType, WalletProcessPsbtResult}; use bitcoind::bitcoincore_rpc::{self, RpcApi}; - use payjoin::receive::v1::build_v1_pj_uri; + use payjoin::receive::common::build_v1_pj_uri; use payjoin::receive::InputPair; use payjoin::{ImplementationError, OutputSubstitution, PjUri, Request, Uri}; use payjoin_test_utils::{init_bitcoind_sender_receiver, init_tracing, BoxError}; @@ -1069,14 +1069,14 @@ mod integration { // In production it it will come in as an HTTP request (over ssl or onion) fn handle_v1_pj_request( req: Request, - headers: impl payjoin::receive::v1::Headers, + headers: impl payjoin::receive::common::Headers, receiver: &bitcoincore_rpc::Client, custom_outputs: Option>, drain_script: Option<&bitcoin::Script>, custom_inputs: Option>, ) -> Result { // Receiver receive payjoin proposal, IRL it will be an HTTP request (over ssl or onion) - let proposal = payjoin::receive::v1::UncheckedProposal::from_request( + let proposal = payjoin::receive::common::UncheckedProposal::from_request( req.body.as_slice(), req.url.query().unwrap_or(""), headers, @@ -1089,12 +1089,12 @@ mod integration { } fn handle_proposal( - proposal: payjoin::receive::v1::UncheckedProposal, + proposal: payjoin::receive::common::UncheckedProposal, receiver: &bitcoincore_rpc::Client, custom_outputs: Option>, drain_script: Option<&bitcoin::Script>, custom_inputs: Option>, - ) -> Result { + ) -> Result { // Receive Check 1: Can Broadcast let proposal = proposal.check_broadcast_suitability(None, |tx| { Ok(receiver @@ -1238,7 +1238,7 @@ mod integration { struct HeaderMock(HashMap); - impl payjoin::receive::v1::Headers for HeaderMock { + impl payjoin::receive::common::Headers for HeaderMock { fn get_header(&self, key: &str) -> Option<&str> { self.0.get(key).map(|e| e.as_str()) } } From 9ac5afcc26db51e2ac7d01e93cc1f46db35f2270 Mon Sep 17 00:00:00 2001 From: DanGould Date: Tue, 26 Aug 2025 15:28:15 -0400 Subject: [PATCH 04/22] Move receive::common::exclusive to receive::common::v1 --- payjoin/src/core/receive/common/mod.rs | 4 ++-- payjoin/src/core/receive/common/{exclusive => v1}/error.rs | 0 payjoin/src/core/receive/common/{exclusive => v1}/mod.rs | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename payjoin/src/core/receive/common/{exclusive => v1}/error.rs (100%) rename payjoin/src/core/receive/common/{exclusive => v1}/mod.rs (100%) diff --git a/payjoin/src/core/receive/common/mod.rs b/payjoin/src/core/receive/common/mod.rs index 99e59ccc3..ddcae0076 100644 --- a/payjoin/src/core/receive/common/mod.rs +++ b/payjoin/src/core/receive/common/mod.rs @@ -51,9 +51,9 @@ use crate::receive::{InternalPayloadError, Original, PsbtContext}; use crate::ImplementationError; #[cfg(feature = "v1")] -mod exclusive; +mod v1; #[cfg(feature = "v1")] -pub use exclusive::*; +pub use v1::*; /// Typestate which the receiver may substitute or add outputs to. /// diff --git a/payjoin/src/core/receive/common/exclusive/error.rs b/payjoin/src/core/receive/common/v1/error.rs similarity index 100% rename from payjoin/src/core/receive/common/exclusive/error.rs rename to payjoin/src/core/receive/common/v1/error.rs diff --git a/payjoin/src/core/receive/common/exclusive/mod.rs b/payjoin/src/core/receive/common/v1/mod.rs similarity index 100% rename from payjoin/src/core/receive/common/exclusive/mod.rs rename to payjoin/src/core/receive/common/v1/mod.rs From 993258ab7cbfa0588b23859b449f2d8d6707989c Mon Sep 17 00:00:00 2001 From: DanGould Date: Tue, 26 Aug 2025 15:50:46 -0400 Subject: [PATCH 05/22] Move receive::common test fn to receive module --- payjoin/src/core/receive/common/v1/mod.rs | 8 +---- payjoin/src/core/receive/mod.rs | 11 ++++-- payjoin/src/core/receive/v2/mod.rs | 2 +- payjoin/src/core/receive/v2/session.rs | 43 +++++++++++------------ 4 files changed, 32 insertions(+), 32 deletions(-) diff --git a/payjoin/src/core/receive/common/v1/mod.rs b/payjoin/src/core/receive/common/v1/mod.rs index 708d481df..38bdf2c87 100644 --- a/payjoin/src/core/receive/common/v1/mod.rs +++ b/payjoin/src/core/receive/common/v1/mod.rs @@ -313,13 +313,7 @@ pub(crate) mod test { 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 } - } - + // TODO: restrict to v1 only since UncheckedProposal is a v1 only type pub(crate) 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]) diff --git a/payjoin/src/core/receive/mod.rs b/payjoin/src/core/receive/mod.rs index b54164509..ce450ab33 100644 --- a/payjoin/src/core/receive/mod.rs +++ b/payjoin/src/core/receive/mod.rs @@ -458,7 +458,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}; @@ -467,7 +467,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; @@ -476,6 +476,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/v2/mod.rs b/payjoin/src/core/receive/v2/mod.rs index 3500b63af..350e83d10 100644 --- a/payjoin/src/core/receive/v2/mod.rs +++ b/payjoin/src/core/receive/v2/mod.rs @@ -1314,7 +1314,7 @@ pub mod test { let context = SessionContext { expiry: now, ..SHARED_CONTEXT.clone() }; let receiver = Receiver { state: UncheckedProposal { - original: crate::receive::common::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 753231351..ee72780cb 100644 --- a/payjoin/src/core/receive/v2/session.rs +++ b/payjoin/src/core/receive/v2/session.rs @@ -178,9 +178,8 @@ mod tests { use super::*; use crate::persist::test_utils::InMemoryTestPersister; - use crate::receive::common::test::{ - proposal_from_test_vector, unchecked_proposal_from_test_vector, - }; + use crate::receive::common::test::unchecked_proposal_from_test_vector; + use crate::receive::tests::original_from_test_vector; use crate::receive::v2::test::SHARED_CONTEXT; use crate::receive::v2::{ Initialized, MaybeInputsOwned, PayjoinProposal, ProvisionalProposal, UncheckedProposal, @@ -188,7 +187,7 @@ mod tests { #[test] fn test_session_event_serialization_roundtrip() { - let proposal = crate::receive::common::test::proposal_from_test_vector(); + let original = original_from_test_vector(); let unchecked_proposal = unchecked_proposal_from_test_vector(); let maybe_inputs_owned = unchecked_proposal.clone().assume_interactive_receiver(); let maybe_inputs_seen = maybe_inputs_owned @@ -213,8 +212,8 @@ mod tests { 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(), @@ -279,19 +278,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 +300,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 +329,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) @@ -352,13 +351,13 @@ mod tests { fn getting_fallback_tx() -> Result<(), BoxError> { let session_context = SHARED_CONTEXT.clone(); let mut events = vec![]; - let proposal = proposal_from_test_vector(); + let original = original_from_test_vector(); let maybe_inputs_owned = unchecked_proposal_from_test_vector().assume_interactive_receiver(); 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 +367,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) @@ -379,7 +378,7 @@ mod tests { let session_context = SHARED_CONTEXT.clone(); let mut events = vec![]; - let proposal = proposal_from_test_vector(); + let original = original_from_test_vector(); let maybe_inputs_owned = unchecked_proposal_from_test_vector().assume_interactive_receiver(); let maybe_inputs_seen = maybe_inputs_owned @@ -403,7 +402,7 @@ mod tests { 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()); @@ -435,7 +434,7 @@ mod tests { let session_context = SHARED_CONTEXT.clone(); let mut events = vec![]; - let proposal = proposal_from_test_vector(); + let original = original_from_test_vector(); let maybe_inputs_owned = unchecked_proposal_from_test_vector().assume_interactive_receiver(); let maybe_inputs_seen = maybe_inputs_owned @@ -463,7 +462,7 @@ mod tests { 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()); From ff70bef05ee9f3183ada4e31a52e119e99ab9d82 Mon Sep 17 00:00:00 2001 From: DanGould Date: Tue, 26 Aug 2025 15:51:29 -0400 Subject: [PATCH 06/22] Remove unnecessary PayjoinProposal field --- payjoin/src/core/receive/common/mod.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/payjoin/src/core/receive/common/mod.rs b/payjoin/src/core/receive/common/mod.rs index ddcae0076..dd3acf6a2 100644 --- a/payjoin/src/core/receive/common/mod.rs +++ b/payjoin/src/core/receive/common/mod.rs @@ -522,7 +522,6 @@ impl WantsFeeRange { 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, }) } } @@ -535,7 +534,6 @@ impl WantsFeeRange { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ProvisionalProposal { pub(crate) psbt_context: PsbtContext, - params: Params, } impl ProvisionalProposal { From 68987d06ea36c58104df67489558c1f1954d5d8c Mon Sep 17 00:00:00 2001 From: DanGould Date: Tue, 26 Aug 2025 16:35:44 -0400 Subject: [PATCH 07/22] Simplify visibility of core receive structs --- payjoin/src/core/receive/mod.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/payjoin/src/core/receive/mod.rs b/payjoin/src/core/receive/mod.rs index ce450ab33..553e749a7 100644 --- a/payjoin/src/core/receive/mod.rs +++ b/payjoin/src/core/receive/mod.rs @@ -243,7 +243,7 @@ pub(crate) fn parse_payload( #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub(crate) struct PsbtContext { original_psbt: Psbt, - pub(crate) payjoin_psbt: Psbt, + payjoin_psbt: Psbt, } impl PsbtContext { @@ -312,7 +312,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( + pub fn finalize_proposal( self, wallet_process_psbt: impl Fn(&Psbt) -> Result, ) -> Result { @@ -338,8 +338,8 @@ impl PsbtContext { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct Original { - pub(crate) psbt: Psbt, - pub(crate) params: Params, + psbt: Psbt, + params: Params, } impl Original { From 66302afe9ff44d7e383712a6efe1bbb971a2739f Mon Sep 17 00:00:00 2001 From: DanGould Date: Tue, 26 Aug 2025 17:07:40 -0400 Subject: [PATCH 08/22] Abstract v1-exclusive ProvisionalProposal onward Make intenal WantsFeeRange::_apply_fee_range function and external v1-exclusive after which the v1 typestates can be used since the v2 sumtype can use the underlying type directly (or eventually encapsulate them itself). --- payjoin/src/core/receive/common/mod.rs | 55 +--------------- payjoin/src/core/receive/common/v1/mod.rs | 79 ++++++++++++++++++++++- payjoin/src/core/receive/mod.rs | 6 +- payjoin/src/core/receive/v2/mod.rs | 34 +++++----- payjoin/src/core/receive/v2/session.rs | 47 ++++++-------- 5 files changed, 120 insertions(+), 101 deletions(-) diff --git a/payjoin/src/core/receive/common/mod.rs b/payjoin/src/core/receive/common/mod.rs index dd3acf6a2..edddc05b9 100644 --- a/payjoin/src/core/receive/common/mod.rs +++ b/payjoin/src/core/receive/common/mod.rs @@ -48,7 +48,6 @@ use super::{InputPair, OutputSubstitutionError, ReplyableError, SelectionError}; use crate::output_substitution::OutputSubstitution; use crate::psbt::PsbtExt; use crate::receive::{InternalPayloadError, Original, PsbtContext}; -use crate::ImplementationError; #[cfg(feature = "v1")] mod v1; @@ -514,60 +513,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( + pub(crate) fn _apply_fee_range( mut self, min_fee_rate: Option, max_effective_fee_rate: Option, - ) -> Result { + ) -> 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 }, - }) - } -} - -/// Typestate for a checked proposal which had both the outputs and the inputs modified -/// by the receiver. The receiver may sign and finalize the Payjoin proposal which will be sent to -/// the sender for their signature. -/// -/// Call [`Self::finalize_proposal`] to return a finalized [`PayjoinProposal`]. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct ProvisionalProposal { - pub(crate) psbt_context: PsbtContext, -} - -impl ProvisionalProposal { - /// Finalizes the Payjoin proposal into a PSBT which the sender will find acceptable before - /// they sign the transaction and broadcast it to the network. - /// - /// 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 fn finalize_proposal( - self, - wallet_process_psbt: impl Fn(&Psbt) -> Result, - ) -> Result { - let finalized_psbt = self - .psbt_context - .finalize_proposal(wallet_process_psbt) - .map_err(|e| ReplyableError::Implementation(ImplementationError::new(e)))?; - Ok(PayjoinProposal { payjoin_psbt: finalized_psbt }) + Ok(PsbtContext { original_psbt: self.original_psbt, payjoin_psbt: psbt }) } } - -/// A finalized Payjoin proposal, complete with fees and receiver signatures, that the sender -/// should find acceptable. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct PayjoinProposal { - payjoin_psbt: Psbt, -} - -impl PayjoinProposal { - /// The UTXOs that would be spent by this Payjoin transaction. - pub fn utxos_to_be_locked(&self) -> impl '_ + Iterator { - self.payjoin_psbt.unsigned_tx.input.iter().map(|input| &input.previous_output) - } - - /// The Payjoin Proposal PSBT. - pub fn psbt(&self) -> &Psbt { &self.payjoin_psbt } -} diff --git a/payjoin/src/core/receive/common/v1/mod.rs b/payjoin/src/core/receive/common/v1/mod.rs index 38bdf2c87..5e788bc41 100644 --- a/payjoin/src/core/receive/common/v1/mod.rs +++ b/payjoin/src/core/receive/common/v1/mod.rs @@ -5,7 +5,7 @@ pub use error::RequestError; use super::*; use crate::uri::PjParam; -use crate::{IntoUrl, PjParseError, Version}; +use crate::{ImplementationError, IntoUrl, PjParseError, Version}; const SUPPORTED_VERSIONS: &[Version] = &[Version::One]; @@ -214,6 +214,83 @@ fn validate_body(headers: impl Headers, body: &[u8]) -> Result<&[u8], RequestErr Ok(body) } +impl super::WantsFeeRange { + /// 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 fn apply_fee_range( + self, + min_fee_rate: Option, + max_effective_fee_rate: Option, + ) -> Result { + let psbt_context = self._apply_fee_range(min_fee_rate, max_effective_fee_rate)?; + Ok(ProvisionalProposal { psbt_context }) + } +} + +/// Typestate for a checked proposal which had both the outputs and the inputs modified +/// by the receiver. The receiver may sign and finalize the Payjoin proposal which will be sent to +/// the sender for their signature. +/// +/// Call [`Self::finalize_proposal`] to return a finalized [`PayjoinProposal`]. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ProvisionalProposal { + pub(crate) psbt_context: PsbtContext, +} + +impl ProvisionalProposal { + /// Finalizes the Payjoin proposal into a PSBT which the sender will find acceptable before + /// they sign the transaction and broadcast it to the network. + /// + /// 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 fn finalize_proposal( + self, + wallet_process_psbt: impl Fn(&Psbt) -> Result, + ) -> Result { + let finalized_psbt = self + .psbt_context + .finalize_proposal(wallet_process_psbt) + .map_err(|e| ReplyableError::Implementation(ImplementationError::new(e)))?; + Ok(PayjoinProposal { payjoin_psbt: finalized_psbt }) + } +} + +/// A finalized Payjoin proposal, complete with fees and receiver signatures, that the sender +/// should find acceptable. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PayjoinProposal { + payjoin_psbt: Psbt, +} + +impl PayjoinProposal { + /// The UTXOs that would be spent by this Payjoin transaction. + pub fn utxos_to_be_locked(&self) -> impl '_ + Iterator { + self.payjoin_psbt.unsigned_tx.input.iter().map(|input| &input.previous_output) + } + + /// The Payjoin Proposal PSBT. + pub fn psbt(&self) -> &Psbt { &self.payjoin_psbt } +} + #[cfg(test)] mod tests { use bitcoin::{Address, AddressType}; diff --git a/payjoin/src/core/receive/mod.rs b/payjoin/src/core/receive/mod.rs index 553e749a7..6c65459c4 100644 --- a/payjoin/src/core/receive/mod.rs +++ b/payjoin/src/core/receive/mod.rs @@ -241,14 +241,14 @@ pub(crate) fn parse_payload( } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub(crate) struct PsbtContext { +pub struct PsbtContext { original_psbt: Psbt, payjoin_psbt: Psbt, } impl PsbtContext { /// Prepare the PSBT by creating a new PSBT and copying only the fields allowed by the [spec](https://github.com/bitcoin/bips/blob/master/bip-0078.mediawiki#senders-payjoin-proposal-checklist) - fn prepare_psbt(self, processed_psbt: Psbt) -> Psbt { + pub(crate) fn prepare_psbt(self, processed_psbt: Psbt) -> Psbt { log::trace!("Original PSBT from callback: {processed_psbt:#?}"); // Create a new PSBT and copy only the allowed fields @@ -312,7 +312,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 fn finalize_proposal( + pub(crate) fn finalize_proposal( self, wallet_process_psbt: impl Fn(&Psbt) -> Result, ) -> Result { diff --git a/payjoin/src/core/receive/v2/mod.rs b/payjoin/src/core/receive/v2/mod.rs index 350e83d10..c547b1cd6 100644 --- a/payjoin/src/core/receive/v2/mod.rs +++ b/payjoin/src/core/receive/v2/mod.rs @@ -162,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), @@ -881,33 +881,31 @@ impl Receiver { min_fee_rate: Option, max_effective_fee_rate: Option, ) -> MaybeFatalTransition, ReplyableError> { - let inner = match self.state.inner.apply_fee_range(min_fee_rate, max_effective_fee_rate) { - Ok(inner) => inner, - Err(e) => { - return MaybeFatalTransition::fatal( - SessionEvent::SessionInvalid(e.to_string(), Some(JsonReply::from(&e))), - e, - ); - } - }; + let psbt_context = + match self.state.inner._apply_fee_range(min_fee_rate, max_effective_fee_rate) { + Ok(inner) => inner, + Err(e) => { + return MaybeFatalTransition::fatal( + SessionEvent::SessionInvalid(e.to_string(), Some(JsonReply::from(&e))), + e, + ); + } + }; 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, - inner: common::ProvisionalProposal, - ) -> ReceiveSession { + pub(crate) fn apply_provisional_proposal(self, psbt_context: PsbtContext) -> ReceiveSession { let new_state = Receiver { state: ProvisionalProposal { - psbt_context: inner.psbt_context, + psbt_context, session_context: self.state.session_context, }, }; diff --git a/payjoin/src/core/receive/v2/session.rs b/payjoin/src/core/receive/v2/session.rs index ee72780cb..b016f14f8 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::{common, 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, }) } @@ -163,7 +163,7 @@ pub enum SessionEvent { WantsOutputs(common::WantsOutputs), WantsInputs(common::WantsInputs), WantsFeeRange(common::WantsFeeRange), - ProvisionalProposal(common::ProvisionalProposal), + 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 @@ -204,8 +204,8 @@ mod tests { .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 payjoin_proposal = provisional_proposal + let psbt_context = wants_fee_range.clone()._apply_fee_range(None, None).unwrap(); + let psbt = psbt_context .clone() .finalize_proposal(|psbt| Ok(psbt.clone())) .expect("Payjoin proposal should be finalized"); @@ -220,8 +220,8 @@ mod tests { SessionEvent::WantsOutputs(wants_outputs), SessionEvent::WantsInputs(wants_inputs), SessionEvent::WantsFeeRange(wants_fee_range), - SessionEvent::ProvisionalProposal(provisional_proposal), - SessionEvent::PayjoinProposal(payjoin_proposal.psbt().clone()), + SessionEvent::ProvisionalProposal(psbt_context), + SessionEvent::PayjoinProposal(psbt.clone()), ]; for event in test_cases { @@ -395,9 +395,9 @@ mod tests { .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 + let psbt_context = wants_fee_range .clone() - .apply_fee_range(None, None) + ._apply_fee_range(None, None) .expect("Contributed inputs should be valid"); let expected_fallback = maybe_inputs_owned.extract_tx_to_schedule_broadcast(); @@ -409,21 +409,16 @@ mod tests { 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::ProvisionalProposal(psbt_context.clone())); let test = SessionHistoryTest { events, expected_session_history: SessionHistoryExpectedOutcome { - psbt_with_fee_contributions: Some( - provisional_proposal.psbt_context.payjoin_psbt.clone(), - ), + psbt_with_fee_contributions: Some(psbt_context.payjoin_psbt.clone()), fallback_tx: Some(expected_fallback), }, expected_receiver_state: ReceiveSession::ProvisionalProposal(Receiver { - state: ProvisionalProposal { - psbt_context: provisional_proposal.psbt_context, - session_context, - }, + state: ProvisionalProposal { psbt_context: psbt_context, session_context }, }), }; run_session_history_test(test) @@ -451,11 +446,11 @@ mod tests { .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 + let psbt_context = wants_fee_range .clone() - .apply_fee_range(None, None) + ._apply_fee_range(None, None) .expect("Contributed inputs should be valid"); - let payjoin_proposal = provisional_proposal + let psbt = psbt_context .clone() .finalize_proposal(|psbt| Ok(psbt.clone())) .expect("Payjoin proposal should be finalized"); @@ -469,19 +464,17 @@ mod tests { 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::PayjoinProposal(payjoin_proposal.psbt().clone())); + events.push(SessionEvent::ProvisionalProposal(psbt_context.clone())); + events.push(SessionEvent::PayjoinProposal(psbt.clone())); let test = SessionHistoryTest { events, expected_session_history: SessionHistoryExpectedOutcome { - psbt_with_fee_contributions: Some( - provisional_proposal.psbt_context.payjoin_psbt.clone(), - ), + psbt_with_fee_contributions: Some(psbt_context.payjoin_psbt.clone()), fallback_tx: Some(expected_fallback), }, expected_receiver_state: ReceiveSession::PayjoinProposal(Receiver { - state: PayjoinProposal { psbt: payjoin_proposal.psbt().clone(), session_context }, + state: PayjoinProposal { psbt: psbt.clone(), session_context }, }), }; run_session_history_test(test) From 430db083766ac533de710bca01409f4158f3618f Mon Sep 17 00:00:00 2001 From: DanGould Date: Tue, 26 Aug 2025 17:44:59 -0400 Subject: [PATCH 09/22] Use v2 logic in v2 tests --- payjoin/src/core/receive/v2/session.rs | 126 +++++++++++++++++-------- 1 file changed, 86 insertions(+), 40 deletions(-) diff --git a/payjoin/src/core/receive/v2/session.rs b/payjoin/src/core/receive/v2/session.rs index b016f14f8..85f0f59d1 100644 --- a/payjoin/src/core/receive/v2/session.rs +++ b/payjoin/src/core/receive/v2/session.rs @@ -178,36 +178,53 @@ mod tests { use super::*; use crate::persist::test_utils::InMemoryTestPersister; - use crate::receive::common::test::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 persister = NoopSessionPersister::::default(); + let original = original_from_test_vector(); - let unchecked_proposal = unchecked_proposal_from_test_vector(); - let maybe_inputs_owned = unchecked_proposal.clone().assume_interactive_receiver(); + let unchecked_proposal = unchecked_receiver_from_test_vector(); + let maybe_inputs_owned = + unchecked_proposal.clone().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 psbt_context = wants_fee_range.clone()._apply_fee_range(None, None).unwrap(); - let psbt = psbt_context + let wants_inputs = wants_outputs.clone().commit_outputs().save(&persister).unwrap(); + let wants_fee_range = wants_inputs.clone().commit_inputs().save(&persister).unwrap(); + let provisional_proposal = + wants_fee_range.clone().apply_fee_range(None, None).save(&persister).unwrap(); + let payjoin_proposal = provisional_proposal .clone() .finalize_proposal(|psbt| Ok(psbt.clone())) + .save(&persister) .expect("Payjoin proposal should be finalized"); let test_cases = vec![ @@ -217,11 +234,11 @@ mod tests { SessionEvent::MaybeInputsOwned(), SessionEvent::MaybeInputsSeen(), SessionEvent::OutputsUnknown(), - SessionEvent::WantsOutputs(wants_outputs), - SessionEvent::WantsInputs(wants_inputs), - SessionEvent::WantsFeeRange(wants_fee_range), - SessionEvent::ProvisionalProposal(psbt_context), - SessionEvent::PayjoinProposal(psbt.clone()), + 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()), ]; for event in test_cases { @@ -349,11 +366,14 @@ mod tests { #[test] fn getting_fallback_tx() -> Result<(), BoxError> { + let persister = NoopSessionPersister::::default(); let session_context = SHARED_CONTEXT.clone(); let mut events = vec![]; let original = original_from_test_vector(); - let maybe_inputs_owned = - unchecked_proposal_from_test_vector().assume_interactive_receiver(); + 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())); @@ -375,29 +395,36 @@ mod tests { #[test] fn test_contributed_inputs() -> Result<(), BoxError> { + let persister = InMemoryTestPersister::::default(); let session_context = SHARED_CONTEXT.clone(); let mut events = vec![]; let original = original_from_test_vector(); - let maybe_inputs_owned = - unchecked_proposal_from_test_vector().assume_interactive_receiver(); + 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 psbt_context = wants_fee_range + let wants_inputs = wants_outputs.clone().commit_outputs().save(&persister).unwrap(); + let wants_fee_range = wants_inputs.clone().commit_inputs().save(&persister).unwrap(); + let provisional_proposal = wants_fee_range .clone() - ._apply_fee_range(None, None) + .apply_fee_range(None, None) + .save(&persister) .expect("Contributed inputs should be valid"); let expected_fallback = maybe_inputs_owned.extract_tx_to_schedule_broadcast(); @@ -406,19 +433,26 @@ mod tests { 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(psbt_context.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(psbt_context.payjoin_psbt.clone()), + psbt_with_fee_contributions: Some( + provisional_proposal.state.psbt_context.payjoin_psbt.clone(), + ), fallback_tx: Some(expected_fallback), }, expected_receiver_state: ReceiveSession::ProvisionalProposal(Receiver { - state: ProvisionalProposal { psbt_context: psbt_context, session_context }, + state: ProvisionalProposal { + psbt_context: provisional_proposal.state.psbt_context.clone(), + session_context, + }, }), }; run_session_history_test(test) @@ -426,33 +460,41 @@ mod tests { #[test] fn test_payjoin_proposal() -> Result<(), BoxError> { + let persister = NoopSessionPersister::::default(); let session_context = SHARED_CONTEXT.clone(); let mut events = vec![]; let original = original_from_test_vector(); - let maybe_inputs_owned = - unchecked_proposal_from_test_vector().assume_interactive_receiver(); + 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 psbt_context = wants_fee_range + let wants_inputs = wants_outputs.clone().commit_outputs().save(&persister).unwrap(); + let wants_fee_range = wants_inputs.clone().commit_inputs().save(&persister).unwrap(); + let provisional_proposal = wants_fee_range .clone() - ._apply_fee_range(None, None) + .apply_fee_range(None, None) + .save(&persister) .expect("Contributed inputs should be valid"); - let psbt = psbt_context + 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(); @@ -461,20 +503,24 @@ mod tests { 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(psbt_context.clone())); - events.push(SessionEvent::PayjoinProposal(psbt.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(psbt_context.payjoin_psbt.clone()), + psbt_with_fee_contributions: Some( + provisional_proposal.state.psbt_context.payjoin_psbt.clone(), + ), fallback_tx: Some(expected_fallback), }, expected_receiver_state: ReceiveSession::PayjoinProposal(Receiver { - state: PayjoinProposal { psbt: psbt.clone(), session_context }, + state: PayjoinProposal { psbt: payjoin_proposal.psbt().clone(), session_context }, }), }; run_session_history_test(test) From f6190171d1d62544a7c309cfdd0db0889a9fe046 Mon Sep 17 00:00:00 2001 From: DanGould Date: Tue, 26 Aug 2025 17:47:49 -0400 Subject: [PATCH 10/22] Make receive::common::v1::test private --- payjoin/src/core/receive/common/v1/mod.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/payjoin/src/core/receive/common/v1/mod.rs b/payjoin/src/core/receive/common/v1/mod.rs index 5e788bc41..4ea0fe8be 100644 --- a/payjoin/src/core/receive/common/v1/mod.rs +++ b/payjoin/src/core/receive/common/v1/mod.rs @@ -367,7 +367,7 @@ mod tests { } #[cfg(test)] -pub(crate) mod test { +pub mod test { use std::str::FromStr; use bitcoin::absolute::{LockTime, Time}; @@ -390,15 +390,14 @@ pub(crate) mod test { use crate::receive::PayloadError; use crate::Version; - // TODO: restrict to v1 only since UncheckedProposal is a v1 only type - pub(crate) fn unchecked_proposal_from_test_vector() -> UncheckedProposal { + 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"); From 4e307d80bb5fe951b28068a2ffb0812506b96815 Mon Sep 17 00:00:00 2001 From: DanGould Date: Tue, 26 Aug 2025 18:43:28 -0400 Subject: [PATCH 11/22] Move receive::common::v1 to receive::v1 --- payjoin-cli/src/app/v1.rs | 10 +-- payjoin/src/core/receive/common/mod.rs | 82 ++++++----------- payjoin/src/core/receive/error.rs | 2 +- payjoin/src/core/receive/mod.rs | 5 +- .../src/core/receive/{common => }/v1/error.rs | 0 .../src/core/receive/{common => }/v1/mod.rs | 88 ++++++++++++------- payjoin/tests/integration.rs | 12 +-- 7 files changed, 98 insertions(+), 101 deletions(-) rename payjoin/src/core/receive/{common => }/v1/error.rs (100%) rename payjoin/src/core/receive/{common => }/v1/mod.rs (95%) diff --git a/payjoin-cli/src/app/v1.rs b/payjoin-cli/src/app/v1.rs index 57473771a..d9ea076b2 100644 --- a/payjoin-cli/src/app/v1.rs +++ b/payjoin-cli/src/app/v1.rs @@ -13,7 +13,7 @@ use hyper::{Method, Request, Response, StatusCode}; use hyper_util::rt::TokioIo; use payjoin::bitcoin::psbt::Psbt; use payjoin::bitcoin::{Amount, FeeRate}; -use payjoin::receive::common::{PayjoinProposal, UncheckedProposal}; +use payjoin::receive::v1::{PayjoinProposal, UncheckedProposal}; use payjoin::receive::ReplyableError::{self, Implementation, V1}; use payjoin::send::v1::SenderBuilder; use payjoin::{ImplementationError, IntoUrl, Uri, UriExt}; @@ -27,7 +27,7 @@ use crate::app::{handle_interrupt, http_agent}; use crate::db::Database; struct Headers<'a>(&'a hyper::HeaderMap); -impl payjoin::receive::common::Headers for Headers<'_> { +impl payjoin::receive::v1::Headers for Headers<'_> { fn get_header(&self, key: &str) -> Option<&str> { self.0.get(key).map(|v| v.to_str()).transpose().ok().flatten() } @@ -117,7 +117,7 @@ impl App { fn construct_payjoin_uri(&self, amount: Amount, endpoint: impl IntoUrl) -> Result { let pj_receiver_address = self.wallet.get_new_address()?; - let mut pj_uri = payjoin::receive::common::build_v1_pj_uri( + let mut pj_uri = payjoin::receive::v1::build_v1_pj_uri( &pj_receiver_address, endpoint, payjoin::OutputSubstitution::Enabled, @@ -374,9 +374,9 @@ impl App { } fn try_contributing_inputs( - payjoin: payjoin::receive::common::WantsInputs, + payjoin: payjoin::receive::v1::WantsInputs, wallet: &BitcoindWallet, -) -> Result { +) -> Result { let candidate_inputs = wallet.list_unspent().map_err(|e| ImplementationError::from(e.into_boxed_dyn_error()))?; diff --git a/payjoin/src/core/receive/common/mod.rs b/payjoin/src/core/receive/common/mod.rs index edddc05b9..af9aa8551 100644 --- a/payjoin/src/core/receive/common/mod.rs +++ b/payjoin/src/core/receive/common/mod.rs @@ -1,35 +1,6 @@ -//! Receive BIP 78 Payjoin v1 -//! -//! This module contains types and methods used to receive payjoin via BIP78. -//! Usage is pretty simple: -//! -//! 1. Generate a pj_uri [BIP 21](https://github.com/bitcoin/bips/blob/master/bip-0021.mediawiki) -//! using [`build_v1_pj_uri`] -//! 2. Listen for a sender's request on the `pj` endpoint -//! 3. Parse the request using -//! [`UncheckedProposal::from_request()`] -//! 4. Validate the proposal using the `check` methods to guide you. -//! 5. Assuming the proposal is valid, augment it into a payjoin with the available -//! `try_preserving_privacy` and `contribute` methods -//! 6. Extract the payjoin PSBT and sign it -//! 7. Respond to the sender's http request with the signed PSBT as payload. -//! -//! The `receive` feature provides all of the check methods, PSBT data manipulation, coin -//! selection, and transport structures to receive payjoin and handle errors in a privacy -//! preserving way. -//! -//! Receiving payjoin entails listening to a secure http endpoint for inbound requests. The -//! endpoint is displayed in the `pj` parameter of a [bip -//! 21](https://github.com/bitcoin/bips/blob/master/bip-0021.mediawiki) request URI. -//! -//! [reference implementation](https://github.com/payjoin/rust-payjoin/tree/master/payjoin-cli) -//! -//! OHTTP Privacy Warning -//! Encapsulated requests whether GET or POST—**must not be retried or reused**. -//! Retransmitting the same ciphertext (including via automatic retries) breaks the unlinkability and privacy guarantees of OHTTP, -//! as it allows the relay to correlate requests by comparing ciphertexts. -//! Note: Even fresh requests may be linkable via metadata (e.g. client IP, request timing), -//! but request reuse makes correlation trivial for the relay. +//! 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}; @@ -49,11 +20,6 @@ use crate::output_substitution::OutputSubstitution; use crate::psbt::PsbtExt; use crate::receive::{InternalPayloadError, Original, PsbtContext}; -#[cfg(feature = "v1")] -mod v1; -#[cfg(feature = "v1")] -pub use v1::*; - /// Typestate which the receiver may substitute or add outputs to. /// /// In addition to contributing new inputs to an existing PSBT, Payjoin allows the @@ -64,11 +30,11 @@ pub use v1::*; /// 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, + pub(crate) original_psbt: Psbt, + pub(crate) payjoin_psbt: Psbt, + pub(crate) params: Params, + pub(crate) change_vout: usize, + pub(crate) owned_vouts: Vec, } impl WantsOutputs { @@ -192,7 +158,11 @@ impl WantsOutputs { /// 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) { +pub(crate) 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 @@ -218,11 +188,11 @@ fn interleave_shuffle(original: &mut Vec, new: &mut [ /// 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, + pub(crate) original_psbt: Psbt, + pub(crate) payjoin_psbt: Psbt, + pub(crate) params: Params, + pub(crate) change_vout: usize, + pub(crate) receiver_inputs: Vec, } impl WantsInputs { @@ -253,7 +223,7 @@ impl WantsInputs { /// value increased by the amount of the candidate input. /// /// Errors if the transaction does not have exactly 2 outputs. - fn avoid_uih( + pub(crate) fn avoid_uih( &self, candidate_inputs: impl IntoIterator, ) -> Result { @@ -389,15 +359,15 @@ impl WantsInputs { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct WantsFeeRange { - original_psbt: Psbt, - payjoin_psbt: Psbt, - params: Params, - change_vout: usize, - receiver_inputs: Vec, + pub(crate) original_psbt: Psbt, + pub(crate) payjoin_psbt: Psbt, + pub(crate) params: Params, + pub(crate) change_vout: usize, + pub(crate) receiver_inputs: Vec, } impl WantsFeeRange { - fn apply_fee( + pub(crate) fn apply_fee( &mut self, min_fee_rate: Option, max_effective_fee_rate: Option, @@ -470,7 +440,7 @@ impl WantsFeeRange { } /// Calculate the additional input weight contributed by the receiver. - fn additional_input_weight(&self) -> Result { + pub(crate) fn additional_input_weight(&self) -> Result { Ok(self.receiver_inputs.iter().map(|input_pair| input_pair.expected_weight).sum()) } diff --git a/payjoin/src/core/receive/error.rs b/payjoin/src/core/receive/error.rs index ee345cff8..b711e0dfd 100644 --- a/payjoin/src/core/receive/error.rs +++ b/payjoin/src/core/receive/error.rs @@ -57,7 +57,7 @@ pub enum ReplyableError { Payload(PayloadError), /// Protocol-specific errors for BIP-78 v1 requests (e.g. HTTP request validation, parameter checks) #[cfg(feature = "v1")] - V1(crate::receive::common::RequestError), + V1(crate::receive::v1::RequestError), /// Error arising due to the specific receiver implementation /// /// e.g. database errors, network failures, wallet errors diff --git a/payjoin/src/core/receive/mod.rs b/payjoin/src/core/receive/mod.rs index 6c65459c4..5d4085be9 100644 --- a/payjoin/src/core/receive/mod.rs +++ b/payjoin/src/core/receive/mod.rs @@ -30,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 common; -#[cfg(not(feature = "v1"))] -pub(crate) mod common; +pub mod v1; #[cfg(feature = "v2")] #[cfg_attr(docsrs, doc(cfg(feature = "v2")))] diff --git a/payjoin/src/core/receive/common/v1/error.rs b/payjoin/src/core/receive/v1/error.rs similarity index 100% rename from payjoin/src/core/receive/common/v1/error.rs rename to payjoin/src/core/receive/v1/error.rs diff --git a/payjoin/src/core/receive/common/v1/mod.rs b/payjoin/src/core/receive/v1/mod.rs similarity index 95% rename from payjoin/src/core/receive/common/v1/mod.rs rename to payjoin/src/core/receive/v1/mod.rs index 4ea0fe8be..61549c7a1 100644 --- a/payjoin/src/core/receive/common/v1/mod.rs +++ b/payjoin/src/core/receive/v1/mod.rs @@ -1,11 +1,45 @@ +//! Receive BIP 78 Payjoin v1 +//! +//! This module contains types and methods used to receive payjoin via BIP78. +//! Usage is pretty simple: +//! +//! 1. Generate a pj_uri [BIP 21](https://github.com/bitcoin/bips/blob/master/bip-0021.mediawiki) +//! using [`build_v1_pj_uri`] +//! 2. Listen for a sender's request on the `pj` endpoint +//! 3. Parse the request using +//! [`UncheckedProposal::from_request()`] +//! 4. Validate the proposal using the `check` methods to guide you. +//! 5. Assuming the proposal is valid, augment it into a payjoin with the available +//! `try_preserving_privacy` and `contribute` methods +//! 6. Extract the payjoin PSBT and sign it +//! 7. Respond to the sender's http request with the signed PSBT as payload. +//! +//! The `receive` feature provides all of the check methods, PSBT data manipulation, coin +//! selection, and transport structures to receive payjoin and handle errors in a privacy +//! preserving way. +//! +//! Receiving payjoin entails listening to a secure http endpoint for inbound requests. The +//! endpoint is displayed in the `pj` parameter of a [bip +//! 21](https://github.com/bitcoin/bips/blob/master/bip-0021.mediawiki) request URI. +//! +//! [reference implementation](https://github.com/payjoin/rust-payjoin/tree/master/payjoin-cli) +//! +//! OHTTP Privacy Warning +//! Encapsulated requests whether GET or POST—**must not be retried or reused**. +//! Retransmitting the same ciphertext (including via automatic retries) breaks the unlinkability and privacy guarantees of OHTTP, +//! as it allows the relay to correlate requests by comparing ciphertexts. +//! Note: Even fresh requests may be linkable via metadata (e.g. client IP, request timing), +//! but request reuse makes correlation trivial for the relay. + mod error; use bitcoin::OutPoint; pub(crate) use error::InternalRequestError; pub use error::RequestError; use super::*; +pub use crate::receive::common::{WantsFeeRange, WantsInputs, WantsOutputs}; use crate::uri::PjParam; -use crate::{ImplementationError, IntoUrl, PjParseError, Version}; +use crate::{IntoUrl, OutputSubstitution, PjParseError, Version}; const SUPPORTED_VERSIONS: &[Version] = &[Version::One]; @@ -214,7 +248,7 @@ fn validate_body(headers: impl Headers, body: &[u8]) -> Result<&[u8], RequestErr Ok(body) } -impl super::WantsFeeRange { +impl crate::receive::common::WantsFeeRange { /// Applies additional fee contribution now that the receiver has contributed inputs /// and may have added new outputs. /// @@ -252,7 +286,7 @@ impl super::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, + psbt_context: PsbtContext, } impl ProvisionalProposal { @@ -293,10 +327,29 @@ impl PayjoinProposal { #[cfg(test)] mod tests { - use bitcoin::{Address, AddressType}; - use payjoin_test_utils::{ORIGINAL_PSBT, QUERY_PARAMS}; + 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::{ + Address, Amount, Network, OutPoint, PubkeyHash, ScriptBuf, Sequence, TapLeafHash, + Transaction, + }; + use payjoin_test_utils::{ + DUMMY20, ORIGINAL_PSBT, PARSED_ORIGINAL_PSBT, QUERY_PARAMS, RECEIVER_INPUT_CONTRIBUTION, + }; use super::*; + use crate::receive::common::interleave_shuffle; + use crate::receive::error::{InternalOutputSubstitutionError, InternalSelectionError}; + use crate::receive::PayloadError; + use crate::Version; #[derive(Debug, Clone)] struct MockHeaders { @@ -364,31 +417,6 @@ mod tests { ); Ok(()) } -} - -#[cfg(test)] -pub mod test { - 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 super::*; - use crate::receive::PayloadError; - use crate::Version; fn unchecked_proposal_from_test_vector() -> UncheckedProposal { let pairs = url::form_urlencoded::parse(QUERY_PARAMS.as_bytes()); diff --git a/payjoin/tests/integration.rs b/payjoin/tests/integration.rs index b6747e69b..ecc36fd50 100644 --- a/payjoin/tests/integration.rs +++ b/payjoin/tests/integration.rs @@ -9,7 +9,7 @@ mod integration { use bitcoin::{Amount, FeeRate, OutPoint, TxIn, TxOut, Weight}; use bitcoind::bitcoincore_rpc::json::{AddressType, WalletProcessPsbtResult}; use bitcoind::bitcoincore_rpc::{self, RpcApi}; - use payjoin::receive::common::build_v1_pj_uri; + use payjoin::receive::v1::build_v1_pj_uri; use payjoin::receive::InputPair; use payjoin::{ImplementationError, OutputSubstitution, PjUri, Request, Uri}; use payjoin_test_utils::{init_bitcoind_sender_receiver, init_tracing, BoxError}; @@ -1069,14 +1069,14 @@ mod integration { // In production it it will come in as an HTTP request (over ssl or onion) fn handle_v1_pj_request( req: Request, - headers: impl payjoin::receive::common::Headers, + headers: impl payjoin::receive::v1::Headers, receiver: &bitcoincore_rpc::Client, custom_outputs: Option>, drain_script: Option<&bitcoin::Script>, custom_inputs: Option>, ) -> Result { // Receiver receive payjoin proposal, IRL it will be an HTTP request (over ssl or onion) - let proposal = payjoin::receive::common::UncheckedProposal::from_request( + let proposal = payjoin::receive::v1::UncheckedProposal::from_request( req.body.as_slice(), req.url.query().unwrap_or(""), headers, @@ -1089,12 +1089,12 @@ mod integration { } fn handle_proposal( - proposal: payjoin::receive::common::UncheckedProposal, + proposal: payjoin::receive::v1::UncheckedProposal, receiver: &bitcoincore_rpc::Client, custom_outputs: Option>, drain_script: Option<&bitcoin::Script>, custom_inputs: Option>, - ) -> Result { + ) -> Result { // Receive Check 1: Can Broadcast let proposal = proposal.check_broadcast_suitability(None, |tx| { Ok(receiver @@ -1238,7 +1238,7 @@ mod integration { struct HeaderMock(HashMap); - impl payjoin::receive::common::Headers for HeaderMock { + impl payjoin::receive::v1::Headers for HeaderMock { fn get_header(&self, key: &str) -> Option<&str> { self.0.get(key).map(|e| e.as_str()) } } From aa5fa745d0c6bb68ee0c9a2b5fde1ffb3ff2f156 Mon Sep 17 00:00:00 2001 From: DanGould Date: Tue, 26 Aug 2025 22:05:44 -0400 Subject: [PATCH 12/22] Refactor WantsOutputs::from_proposal to ::new - have the parameters reflect their new names - add a docstring --- payjoin/src/core/receive/common/mod.rs | 12 ++++++++---- payjoin/src/core/receive/v1/mod.rs | 2 +- payjoin/src/core/receive/v2/mod.rs | 2 +- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/payjoin/src/core/receive/common/mod.rs b/payjoin/src/core/receive/common/mod.rs index af9aa8551..c17b3da6d 100644 --- a/payjoin/src/core/receive/common/mod.rs +++ b/payjoin/src/core/receive/common/mod.rs @@ -143,11 +143,15 @@ impl WantsOutputs { } } - pub(crate) fn from_proposal(proposal: Original, owned_vouts: Vec) -> Self { + /// 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: proposal.psbt.clone(), - payjoin_psbt: proposal.psbt, - params: proposal.params, + original_psbt: original.psbt.clone(), + payjoin_psbt: original.psbt, + params: original.params, change_vout: owned_vouts[0], owned_vouts, } diff --git a/payjoin/src/core/receive/v1/mod.rs b/payjoin/src/core/receive/v1/mod.rs index 61549c7a1..c1c559d47 100644 --- a/payjoin/src/core/receive/v1/mod.rs +++ b/payjoin/src/core/receive/v1/mod.rs @@ -217,7 +217,7 @@ 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)) + Ok(WantsOutputs::new(self.original, owned_vouts)) } } diff --git a/payjoin/src/core/receive/v2/mod.rs b/payjoin/src/core/receive/v2/mod.rs index c547b1cd6..ddb42adfa 100644 --- a/payjoin/src/core/receive/v2/mod.rs +++ b/payjoin/src/core/receive/v2/mod.rs @@ -706,7 +706,7 @@ impl Receiver { } }, }; - let inner = common::WantsOutputs::from_proposal(self.state.original, owned_vouts); + let inner = common::WantsOutputs::new(self.state.original, owned_vouts); MaybeFatalTransition::success( SessionEvent::WantsOutputs(inner.clone()), Receiver { state: WantsOutputs { inner, session_context: self.state.session_context } }, From f6a835a73919c4fcc0c826da45cdadfd49001740 Mon Sep 17 00:00:00 2001 From: DanGould Date: Tue, 26 Aug 2025 22:06:40 -0400 Subject: [PATCH 13/22] Move WantsOutputs::new to the top of impl That's convention. --- payjoin/src/core/receive/common/mod.rs | 28 +++++++++++++------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/payjoin/src/core/receive/common/mod.rs b/payjoin/src/core/receive/common/mod.rs index c17b3da6d..f29c92f30 100644 --- a/payjoin/src/core/receive/common/mod.rs +++ b/payjoin/src/core/receive/common/mod.rs @@ -38,6 +38,20 @@ pub struct WantsOutputs { } 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 } @@ -142,20 +156,6 @@ impl WantsOutputs { receiver_inputs: vec![], } } - - /// 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, - } - } } /// Shuffles `new` vector, then interleaves its elements with those from `original`, From efdd88b2d7b2b6df9ad480edc67d7aebc70fa575 Mon Sep 17 00:00:00 2001 From: DanGould Date: Tue, 26 Aug 2025 22:16:43 -0400 Subject: [PATCH 14/22] Move interleave_shuffle to reduce visibility Untangle it. Call that conditioner. --- payjoin/src/core/receive/common/mod.rs | 36 ++++++++++++++++++++++---- payjoin/src/core/receive/v1/mod.rs | 25 ------------------ 2 files changed, 31 insertions(+), 30 deletions(-) diff --git a/payjoin/src/core/receive/common/mod.rs b/payjoin/src/core/receive/common/mod.rs index f29c92f30..524bfc4b1 100644 --- a/payjoin/src/core/receive/common/mod.rs +++ b/payjoin/src/core/receive/common/mod.rs @@ -162,11 +162,7 @@ impl WantsOutputs { /// maintaining the relative order in `original` but randomly inserting elements from `new`. /// /// The combined result replaces the contents of `original`. -pub(crate) fn interleave_shuffle( - original: &mut Vec, - new: &mut [T], - rng: &mut R, -) { +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 @@ -496,3 +492,33 @@ impl WantsFeeRange { Ok(PsbtContext { original_psbt: self.original_psbt, payjoin_psbt: psbt }) } } + +#[cfg(test)] +mod tests { + use bitcoin::key::rand::rngs::StdRng; + use bitcoin::key::rand::SeedableRng; + + use super::*; + + #[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]); + } +} diff --git a/payjoin/src/core/receive/v1/mod.rs b/payjoin/src/core/receive/v1/mod.rs index c1c559d47..83fd2ebf8 100644 --- a/payjoin/src/core/receive/v1/mod.rs +++ b/payjoin/src/core/receive/v1/mod.rs @@ -332,8 +332,6 @@ mod tests { 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; @@ -346,7 +344,6 @@ mod tests { }; use super::*; - use crate::receive::common::interleave_shuffle; use crate::receive::error::{InternalOutputSubstitutionError, InternalSelectionError}; use crate::receive::PayloadError; use crate::Version; @@ -800,28 +797,6 @@ mod tests { ); } - #[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] From 9aa635a668804ec38929b0c5c7e206689ab18f0f Mon Sep 17 00:00:00 2001 From: DanGould Date: Tue, 26 Aug 2025 22:20:57 -0400 Subject: [PATCH 15/22] Move test_pjos_disabled to common cause it just tests WantsOutputs --- payjoin/src/core/receive/common/mod.rs | 58 +++++++++++++++++++++++++ payjoin/src/core/receive/v1/mod.rs | 59 +------------------------- 2 files changed, 59 insertions(+), 58 deletions(-) diff --git a/payjoin/src/core/receive/common/mod.rs b/payjoin/src/core/receive/common/mod.rs index 524bfc4b1..4c96c5c46 100644 --- a/payjoin/src/core/receive/common/mod.rs +++ b/payjoin/src/core/receive/common/mod.rs @@ -499,6 +499,64 @@ mod tests { use bitcoin::key::rand::SeedableRng; use super::*; + use crate::receive::tests::original_from_test_vector; + + #[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_interleave_shuffle() { diff --git a/payjoin/src/core/receive/v1/mod.rs b/payjoin/src/core/receive/v1/mod.rs index 83fd2ebf8..6991afc8b 100644 --- a/payjoin/src/core/receive/v1/mod.rs +++ b/payjoin/src/core/receive/v1/mod.rs @@ -344,7 +344,7 @@ mod tests { }; use super::*; - use crate::receive::error::{InternalOutputSubstitutionError, InternalSelectionError}; + use crate::receive::error::InternalSelectionError; use crate::receive::PayloadError; use crate::Version; @@ -715,63 +715,6 @@ mod tests { ); } - #[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(); From 51b31d1fafbacd45e1024826ae7e75ef95374247 Mon Sep 17 00:00:00 2001 From: DanGould Date: Tue, 26 Aug 2025 22:24:16 -0400 Subject: [PATCH 16/22] Move test_avoid_uih_one_output to WantsOutputs containing mod --- payjoin/src/core/receive/common/mod.rs | 28 ++++++++++++++++++++++++++ payjoin/src/core/receive/v1/mod.rs | 25 ----------------------- 2 files changed, 28 insertions(+), 25 deletions(-) diff --git a/payjoin/src/core/receive/common/mod.rs b/payjoin/src/core/receive/common/mod.rs index 4c96c5c46..0177eb0f2 100644 --- a/payjoin/src/core/receive/common/mod.rs +++ b/payjoin/src/core/receive/common/mod.rs @@ -495,8 +495,11 @@ impl WantsFeeRange { #[cfg(test)] mod tests { + use std::str::FromStr; + use bitcoin::key::rand::rngs::StdRng; use bitcoin::key::rand::SeedableRng; + use payjoin_test_utils::RECEIVER_INPUT_CONTRIBUTION; use super::*; use crate::receive::tests::original_from_test_vector; @@ -558,6 +561,31 @@ mod tests { ); } + #[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]; diff --git a/payjoin/src/core/receive/v1/mod.rs b/payjoin/src/core/receive/v1/mod.rs index 6991afc8b..517798084 100644 --- a/payjoin/src/core/receive/v1/mod.rs +++ b/payjoin/src/core/receive/v1/mod.rs @@ -715,31 +715,6 @@ mod tests { ); } - #[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" - ); - } - /// Add keypath data to psbt to be prepared and verify it is excluded from the final PSBT /// See: #[test] From 668377aa33b87fe120322195bbe60e077ee7d58b Mon Sep 17 00:00:00 2001 From: DanGould Date: Tue, 26 Aug 2025 22:32:37 -0400 Subject: [PATCH 17/22] Move empty_candidate_inputs to common --- payjoin/src/core/receive/common/mod.rs | 13 +++++++++++ payjoin/src/core/receive/v1/mod.rs | 32 -------------------------- 2 files changed, 13 insertions(+), 32 deletions(-) diff --git a/payjoin/src/core/receive/common/mod.rs b/payjoin/src/core/receive/common/mod.rs index 0177eb0f2..f7717de3b 100644 --- a/payjoin/src/core/receive/common/mod.rs +++ b/payjoin/src/core/receive/common/mod.rs @@ -504,6 +504,19 @@ mod tests { 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(); diff --git a/payjoin/src/core/receive/v1/mod.rs b/payjoin/src/core/receive/v1/mod.rs index 517798084..fbf8a3799 100644 --- a/payjoin/src/core/receive/v1/mod.rs +++ b/payjoin/src/core/receive/v1/mod.rs @@ -344,7 +344,6 @@ mod tests { }; use super::*; - use crate::receive::error::InternalSelectionError; use crate::receive::PayloadError; use crate::Version; @@ -537,37 +536,6 @@ mod tests { } } - #[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(); From 19b2491a2709c93d0f959d91e1068ffc17b033fd Mon Sep 17 00:00:00 2001 From: DanGould Date: Tue, 26 Aug 2025 22:49:29 -0400 Subject: [PATCH 18/22] Add test_multiple_contribute_inputs() to common --- payjoin/src/core/receive/common/mod.rs | 50 ++++++++++++++++++++++++- payjoin/src/core/receive/v1/mod.rs | 52 +------------------------- 2 files changed, 51 insertions(+), 51 deletions(-) diff --git a/payjoin/src/core/receive/common/mod.rs b/payjoin/src/core/receive/common/mod.rs index f7717de3b..8a866a2c9 100644 --- a/payjoin/src/core/receive/common/mod.rs +++ b/payjoin/src/core/receive/common/mod.rs @@ -497,9 +497,13 @@ impl WantsFeeRange { mod tests { use std::str::FromStr; + use bitcoin::absolute::{LockTime, Time}; + use bitcoin::hashes::Hash; use bitcoin::key::rand::rngs::StdRng; use bitcoin::key::rand::SeedableRng; - use payjoin_test_utils::RECEIVER_INPUT_CONTRIBUTION; + use bitcoin::psbt::Input; + use bitcoin::{OutPoint, PubkeyHash, ScriptBuf, Sequence, Transaction}; + use payjoin_test_utils::{DUMMY20, RECEIVER_INPUT_CONTRIBUTION}; use super::*; use crate::receive::tests::original_from_test_vector; @@ -620,4 +624,48 @@ mod tests { 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); + } } diff --git a/payjoin/src/core/receive/v1/mod.rs b/payjoin/src/core/receive/v1/mod.rs index fbf8a3799..4ee08795c 100644 --- a/payjoin/src/core/receive/v1/mod.rs +++ b/payjoin/src/core/receive/v1/mod.rs @@ -331,16 +331,12 @@ mod tests { 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 bitcoin::{Address, Amount, Network, OutPoint, ScriptBuf, TapLeafHash, Transaction}; use payjoin_test_utils::{ - DUMMY20, ORIGINAL_PSBT, PARSED_ORIGINAL_PSBT, QUERY_PARAMS, RECEIVER_INPUT_CONTRIBUTION, + ORIGINAL_PSBT, PARSED_ORIGINAL_PSBT, QUERY_PARAMS, RECEIVER_INPUT_CONTRIBUTION, }; use super::*; @@ -743,50 +739,6 @@ mod tests { } } - #[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(); From 6d75a4b0c800946be4dc1dcaf9a43a1156c0dd85 Mon Sep 17 00:00:00 2001 From: DanGould Date: Tue, 26 Aug 2025 23:15:48 -0400 Subject: [PATCH 19/22] Move rest of tests into common --- payjoin/src/core/receive/common/mod.rs | 220 ++++++++++++++++++++++++- payjoin/src/core/receive/v1/mod.rs | 217 +----------------------- 2 files changed, 221 insertions(+), 216 deletions(-) diff --git a/payjoin/src/core/receive/common/mod.rs b/payjoin/src/core/receive/common/mod.rs index 8a866a2c9..2119fecfa 100644 --- a/payjoin/src/core/receive/common/mod.rs +++ b/payjoin/src/core/receive/common/mod.rs @@ -498,11 +498,17 @@ 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::{OutPoint, PubkeyHash, ScriptBuf, Sequence, Transaction}; + 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::*; @@ -668,4 +674,216 @@ mod tests { 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_range(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()); + } + } } diff --git a/payjoin/src/core/receive/v1/mod.rs b/payjoin/src/core/receive/v1/mod.rs index 4ee08795c..9a696c11d 100644 --- a/payjoin/src/core/receive/v1/mod.rs +++ b/payjoin/src/core/receive/v1/mod.rs @@ -330,14 +330,8 @@ mod tests { use std::str::FromStr; use bitcoin::absolute::{LockTime, Time}; - use bitcoin::bip32::{DerivationPath, Fingerprint, Xpriv, Xpub}; - use bitcoin::psbt::Input; - use bitcoin::secp256k1::Secp256k1; - use bitcoin::taproot::LeafVersion; - use bitcoin::{Address, Amount, Network, OutPoint, ScriptBuf, TapLeafHash, Transaction}; - use payjoin_test_utils::{ - ORIGINAL_PSBT, PARSED_ORIGINAL_PSBT, QUERY_PARAMS, RECEIVER_INPUT_CONTRIBUTION, - }; + use bitcoin::{Address, Amount, Network, Transaction}; + use payjoin_test_utils::{ORIGINAL_PSBT, PARSED_ORIGINAL_PSBT, QUERY_PARAMS}; use super::*; use crate::receive::PayloadError; @@ -532,213 +526,6 @@ mod tests { } } - #[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) - ); - } - - /// 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_finalize_proposal_invalid_payjoin_proposal() { let proposal = unchecked_proposal_from_test_vector(); From 91b5316019ceaaa40cd8ab82dbbecfcd78d59861 Mon Sep 17 00:00:00 2001 From: DanGould Date: Tue, 26 Aug 2025 23:20:36 -0400 Subject: [PATCH 20/22] Move tests to reduce common struct/field/fn scopes --- payjoin/src/core/receive/common/mod.rs | 50 +++++++++++++++++--------- payjoin/src/core/receive/v1/mod.rs | 18 ---------- 2 files changed, 33 insertions(+), 35 deletions(-) diff --git a/payjoin/src/core/receive/common/mod.rs b/payjoin/src/core/receive/common/mod.rs index 2119fecfa..1287baa4d 100644 --- a/payjoin/src/core/receive/common/mod.rs +++ b/payjoin/src/core/receive/common/mod.rs @@ -30,11 +30,11 @@ use crate::receive::{InternalPayloadError, Original, PsbtContext}; /// Call [`Self::commit_outputs`] to proceed. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct WantsOutputs { - pub(crate) original_psbt: Psbt, - pub(crate) payjoin_psbt: Psbt, - pub(crate) params: Params, - pub(crate) change_vout: usize, - pub(crate) owned_vouts: Vec, + original_psbt: Psbt, + payjoin_psbt: Psbt, + params: Params, + change_vout: usize, + owned_vouts: Vec, } impl WantsOutputs { @@ -188,11 +188,11 @@ 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(crate) original_psbt: Psbt, - pub(crate) payjoin_psbt: Psbt, - pub(crate) params: Params, - pub(crate) change_vout: usize, - pub(crate) receiver_inputs: Vec, + original_psbt: Psbt, + payjoin_psbt: Psbt, + params: Params, + change_vout: usize, + receiver_inputs: Vec, } impl WantsInputs { @@ -359,15 +359,15 @@ impl WantsInputs { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct WantsFeeRange { - pub(crate) original_psbt: Psbt, - pub(crate) payjoin_psbt: Psbt, - pub(crate) params: Params, - pub(crate) change_vout: usize, - pub(crate) receiver_inputs: Vec, + original_psbt: Psbt, + payjoin_psbt: Psbt, + params: Params, + change_vout: usize, + receiver_inputs: Vec, } impl WantsFeeRange { - pub(crate) fn apply_fee( + fn apply_fee( &mut self, min_fee_rate: Option, max_effective_fee_rate: Option, @@ -440,7 +440,7 @@ impl WantsFeeRange { } /// Calculate the additional input weight contributed by the receiver. - pub(crate) fn additional_input_weight(&self) -> Result { + fn additional_input_weight(&self) -> Result { Ok(self.receiver_inputs.iter().map(|input_pair| input_pair.expected_weight).sum()) } @@ -886,4 +886,20 @@ mod tests { 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/v1/mod.rs b/payjoin/src/core/receive/v1/mod.rs index 9a696c11d..23152e165 100644 --- a/payjoin/src/core/receive/v1/mod.rs +++ b/payjoin/src/core/receive/v1/mod.rs @@ -508,24 +508,6 @@ mod tests { 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 test_finalize_proposal_invalid_payjoin_proposal() { let proposal = unchecked_proposal_from_test_vector(); From eac5e77205488f019f714540f54ecba0e10aa5fb Mon Sep 17 00:00:00 2001 From: DanGould Date: Wed, 27 Aug 2025 10:25:09 -0400 Subject: [PATCH 21/22] Reduce PsbtContext method visibility --- payjoin/src/core/receive/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/payjoin/src/core/receive/mod.rs b/payjoin/src/core/receive/mod.rs index 5d4085be9..5e9198968 100644 --- a/payjoin/src/core/receive/mod.rs +++ b/payjoin/src/core/receive/mod.rs @@ -247,7 +247,7 @@ pub struct PsbtContext { impl PsbtContext { /// Prepare the PSBT by creating a new PSBT and copying only the fields allowed by the [spec](https://github.com/bitcoin/bips/blob/master/bip-0078.mediawiki#senders-payjoin-proposal-checklist) - pub(crate) fn prepare_psbt(self, processed_psbt: Psbt) -> Psbt { + fn prepare_psbt(self, processed_psbt: Psbt) -> Psbt { log::trace!("Original PSBT from callback: {processed_psbt:#?}"); // Create a new PSBT and copy only the allowed fields @@ -311,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 { From de883a6640101969a8d61c66526a33d747c71168 Mon Sep 17 00:00:00 2001 From: DanGould Date: Wed, 27 Aug 2025 15:11:49 -0400 Subject: [PATCH 22/22] Make .save(&persister).unwrap()s .expect()s as Armin prefers. --- payjoin/src/core/receive/common/mod.rs | 4 ++-- payjoin/src/core/receive/v1/mod.rs | 2 +- payjoin/src/core/receive/v2/mod.rs | 23 ++++++++++-------- payjoin/src/core/receive/v2/session.rs | 32 ++++++++++++++++++-------- 4 files changed, 38 insertions(+), 23 deletions(-) diff --git a/payjoin/src/core/receive/common/mod.rs b/payjoin/src/core/receive/common/mod.rs index 1287baa4d..8dbd7eb98 100644 --- a/payjoin/src/core/receive/common/mod.rs +++ b/payjoin/src/core/receive/common/mod.rs @@ -483,7 +483,7 @@ 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(crate) fn _apply_fee_range( + pub(super) fn apply_fee_to_psbt_context( mut self, min_fee_rate: Option, max_effective_fee_rate: Option, @@ -867,7 +867,7 @@ mod tests { let psbt_context = WantsOutputs::new(original_from_test_vector(), vec![0]) .commit_outputs() .commit_inputs() - ._apply_fee_range(None, None) + .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"); diff --git a/payjoin/src/core/receive/v1/mod.rs b/payjoin/src/core/receive/v1/mod.rs index 23152e165..dda92792b 100644 --- a/payjoin/src/core/receive/v1/mod.rs +++ b/payjoin/src/core/receive/v1/mod.rs @@ -274,7 +274,7 @@ impl crate::receive::common::WantsFeeRange { min_fee_rate: Option, max_effective_fee_rate: Option, ) -> Result { - let psbt_context = self._apply_fee_range(min_fee_rate, max_effective_fee_rate)?; + let psbt_context = self.apply_fee_to_psbt_context(min_fee_rate, max_effective_fee_rate)?; Ok(ProvisionalProposal { psbt_context }) } } diff --git a/payjoin/src/core/receive/v2/mod.rs b/payjoin/src/core/receive/v2/mod.rs index ddb42adfa..71ed3ac90 100644 --- a/payjoin/src/core/receive/v2/mod.rs +++ b/payjoin/src/core/receive/v2/mod.rs @@ -881,16 +881,19 @@ impl Receiver { min_fee_rate: Option, max_effective_fee_rate: Option, ) -> MaybeFatalTransition, ReplyableError> { - let psbt_context = - match self.state.inner._apply_fee_range(min_fee_rate, max_effective_fee_rate) { - Ok(inner) => inner, - Err(e) => { - return MaybeFatalTransition::fatal( - SessionEvent::SessionInvalid(e.to_string(), Some(JsonReply::from(&e))), - e, - ); - } - }; + 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( + SessionEvent::SessionInvalid(e.to_string(), Some(JsonReply::from(&e))), + e, + ); + } + }; MaybeFatalTransition::success( SessionEvent::ProvisionalProposal(psbt_context.clone()), Receiver { diff --git a/payjoin/src/core/receive/v2/session.rs b/payjoin/src/core/receive/v2/session.rs index 85f0f59d1..5f3b7c44d 100644 --- a/payjoin/src/core/receive/v2/session.rs +++ b/payjoin/src/core/receive/v2/session.rs @@ -200,8 +200,11 @@ mod tests { 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).unwrap(); + 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)) @@ -217,10 +220,15 @@ mod tests { .identify_receiver_outputs(&mut |_| Ok(true)) .save(&persister) .expect("Outputs should be identified"); - let wants_inputs = wants_outputs.clone().commit_outputs().save(&persister).unwrap(); - let wants_fee_range = wants_inputs.clone().commit_inputs().save(&persister).unwrap(); - let provisional_proposal = - wants_fee_range.clone().apply_fee_range(None, None).save(&persister).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())) @@ -419,8 +427,10 @@ mod tests { .identify_receiver_outputs(&mut |_| Ok(true)) .save(&persister) .expect("Outputs should be identified"); - let wants_inputs = wants_outputs.clone().commit_outputs().save(&persister).unwrap(); - let wants_fee_range = wants_inputs.clone().commit_inputs().save(&persister).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) @@ -484,8 +494,10 @@ mod tests { .identify_receiver_outputs(&mut |_| Ok(true)) .save(&persister) .expect("Outputs should be identified"); - let wants_inputs = wants_outputs.clone().commit_outputs().save(&persister).unwrap(); - let wants_fee_range = wants_inputs.clone().commit_inputs().save(&persister).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)