diff --git a/payjoin/src/core/receive/common/mod.rs b/payjoin/src/core/receive/common/mod.rs index c3cd1cb95..e6094e208 100644 --- a/payjoin/src/core/receive/common/mod.rs +++ b/payjoin/src/core/receive/common/mod.rs @@ -862,8 +862,7 @@ mod tests { .commit_inputs() .calculate_psbt_context_with_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"); + let payjoin_proposal = psbt_context.finalize_proposal(&processed_psbt).expect("Valid psbt"); assert!(payjoin_proposal.xpub.is_empty()); diff --git a/payjoin/src/core/receive/error.rs b/payjoin/src/core/receive/error.rs index 5d9b02083..23b2f0762 100644 --- a/payjoin/src/core/receive/error.rs +++ b/payjoin/src/core/receive/error.rs @@ -3,6 +3,7 @@ use std::{error, fmt}; use crate::error_codes::ErrorCode::{ self, NotEnoughMoney, OriginalPsbtRejected, Unavailable, VersionUnsupported, }; +use crate::ImplementationError; /// The top-level error type for the payjoin receiver #[derive(Debug)] @@ -29,6 +30,10 @@ impl From for Error { fn from(e: ProtocolError) -> Self { Error::Protocol(e) } } +impl From for Error { + fn from(e: ImplementationError) -> Self { Error::Implementation(e) } +} + impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { diff --git a/payjoin/src/core/receive/mod.rs b/payjoin/src/core/receive/mod.rs index a2be3bc0e..e29b4c62f 100644 --- a/payjoin/src/core/receive/mod.rs +++ b/payjoin/src/core/receive/mod.rs @@ -12,6 +12,7 @@ use std::collections::BTreeMap; use std::str::FromStr; +use bitcoin::hashes::sha256d; use bitcoin::transaction::InputWeightPrediction; use bitcoin::{ psbt, AddressType, FeeRate, OutPoint, Psbt, Script, ScriptBuf, Transaction, TxIn, TxOut, Weight, @@ -228,6 +229,419 @@ impl<'a> From<&'a InputPair> for InternalInputPair<'a> { fn from(pair: &'a InputPair) -> Self { Self { psbtin: &pair.psbtin, txin: &pair.txin } } } +/// Tag used within [`TaggedValidatorReference`] to store a validation result +/// +/// The particular implementation of this trait used is what defines the type of validation +/// [`FinalizedValidator`] is for. +pub trait ValidatorReferenceTag { + /// Create a new tag that contains validation result + fn new(tag: bool) -> Self; + + /// Return whether tag is true within + fn is_true(&self) -> bool; +} + +pub struct InputOwnedTag(bool); +impl ValidatorReferenceTag for InputOwnedTag { + fn new(tag: bool) -> Self { InputOwnedTag(tag) } + fn is_true(&self) -> bool { self.0 } +} + +pub struct InputSeenTag(bool); +impl ValidatorReferenceTag for InputSeenTag { + fn new(tag: bool) -> Self { InputSeenTag(tag) } + fn is_true(&self) -> bool { self.0 } +} + +pub struct OutputOwnedTag(bool); +impl ValidatorReferenceTag for OutputOwnedTag { + fn new(tag: bool) -> Self { OutputOwnedTag(tag) } + fn is_true(&self) -> bool { self.0 } +} + +/// Holds the value to be validated by validator +pub struct ValidatorReference +where + R: Clone, +{ + value: R, + index: usize, +} + +impl ValidatorReference +where + R: Clone, +{ + pub(crate) fn get_value(&self) -> R { self.value.clone() } + pub(crate) fn get_index(&self) -> usize { self.index } + + /// Mark the ValidatorReference with a tag + pub(crate) fn mark(self, tag: T) -> TaggedValidatorReference + where + T: ValidatorReferenceTag, + { + TaggedValidatorReference { reference: self, tag } + } +} + +/// Holds the tagged value to be returned to the validator +pub struct TaggedValidatorReference +where + R: Clone, + T: ValidatorReferenceTag, +{ + reference: ValidatorReference, + tag: T, +} + +impl TaggedValidatorReference +where + R: Clone, + T: ValidatorReferenceTag, +{ + /// Return whether TaggedValidatorReference's tag is true within + pub(crate) fn is_true(&self) -> bool { self.tag.is_true() } +} + +/// Used to apply validation over a list of items. +/// +/// [`Validator::run`] and [`Validator::run_async`] take a validation callback, +/// run it over the Iterator of items to be validated, and return a +/// [`FinalizedValidator`] that holds the result of each item. +struct Validator(I, sha256d::Hash) +where + I: Iterator>, + R: Clone; + +impl Validator +where + I: Iterator>, + R: Clone, +{ + fn new(values: I, identifier: sha256d::Hash) -> Validator { + Validator(values, identifier) + } + /// Takes a synchronous validation callback, applies the validation callback over its wrapped + /// Iterator of [`ValidatorReference`]s, and returns a [`FinalizedValidator`] that + /// holds the result of each item. + pub fn run( + self, + validation_callback: &mut impl FnMut(&R) -> Result, + ) -> Result< + FinalizedValidator>, R, T>, + ImplementationError, + > + where + T: ValidatorReferenceTag, + { + let mut tagged_refs: Vec> = vec![]; + for reference in self.0 { + let tag = T::new(validation_callback(&reference.get_value())?); + let tagged_ref = reference.mark(tag); + tagged_refs.push(tagged_ref); + } + Ok(FinalizedValidator(tagged_refs, self.1)) + } + + /// Takes an asynchronous validation callback, applies the validation callback over its + /// wrapped Iterator of [`ValidatorReference`]s, and returns a [`FinalizedValidator`] + /// that holds the result of each item. + pub async fn run_async( + self, + mut validation_callback: F, + ) -> Result< + FinalizedValidator>, R, T>, + ImplementationError, + > + where + F: FnMut(&R) -> Fut, + Fut: std::future::Future>, + T: ValidatorReferenceTag, + { + let mut tagged_refs: Vec> = vec![]; + for reference in self.0 { + let tag = T::new(validation_callback(&reference.get_value()).await?); + let tagged_ref = reference.mark(tag); + tagged_refs.push(tagged_ref); + } + Ok(FinalizedValidator(tagged_refs, self.1)) + } +} + +/// Runs validation for checking which inputs are owned on original PSBT +/// +/// [`InputsOwnedValidator::run`] takes a validation callback, applies it +/// over each item in the wrapped Iterator, and returns a +/// [`FinalizedValidator`] that holds the result +/// of each item. This is [`InputsOwnedValidator`]'s only purpose. +pub struct InputsOwnedValidator(Validator) +where + I: Iterator>; + +impl InputsOwnedValidator>> { + pub fn new( + psbt: &Psbt, + ) -> Result>>, Error> + { + let input_script_refs: Result>, Error> = psbt + .input_pairs() + .enumerate() + .map(|(index, input)| match input.previous_txout() { + Ok(txout) => + Ok(ValidatorReference { index, value: txout.script_pubkey.to_owned() }), + Err(e) => Err(InternalPayloadError::PrevTxOut(e).into()), + }) + .collect(); + Ok(InputsOwnedValidator(Validator( + input_script_refs?.into_iter(), + psbt.unsigned_tx.compute_ntxid(), + ))) + } +} + +impl InputsOwnedValidator +where + I: Iterator>, +{ + /// Takes a synchronous validation callback, applies the validation callback over its wrapped + /// Iterator of [`ValidatorReference`]s, and returns a [`FinalizedValidator`] that + /// holds the result of each item. + pub fn run( + self, + validation_callback: &mut impl FnMut(&ScriptBuf) -> Result, + ) -> Result< + FinalizedValidator< + impl IntoIterator>, + ScriptBuf, + InputOwnedTag, + >, + ImplementationError, + > { + self.0.run(validation_callback) + } + + /// Takes an asynchronous validation callback, applies the validation callback over its + /// wrapped Iterator of [`ValidatorReference`]s, and returns a [`FinalizedValidator`] + /// that holds the result of each item. + pub async fn run_async( + self, + validation_callback: F, + ) -> Result< + FinalizedValidator< + impl IntoIterator>, + ScriptBuf, + InputOwnedTag, + >, + ImplementationError, + > + where + F: FnMut(&ScriptBuf) -> Fut, + Fut: std::future::Future>, + { + self.0.run_async(validation_callback).await + } +} + +/// Runs validation for checking which inputs have been seen on original PSBT +/// +/// [`InputsSeenValidator::run`] takes a validation callback, applies it +/// over each item in the wrapped Iterator, and returns a +/// [`FinalizedValidator`] that holds the result +/// of each item. This is [`InputsSeenValidator`]'s only purpose. +pub struct InputsSeenValidator(Validator) +where + I: Iterator>; + +impl InputsSeenValidator>> { + pub(crate) fn new( + psbt: &Psbt, + ) -> InputsSeenValidator>> { + let input_outpoint_refs: Vec> = psbt + .input_pairs() + .enumerate() + .map(|(index, input)| ValidatorReference { index, value: input.txin.previous_output }) + .collect(); + InputsSeenValidator(Validator::new( + input_outpoint_refs.into_iter(), + psbt.unsigned_tx.compute_ntxid(), + )) + } +} + +impl InputsSeenValidator +where + I: Iterator>, +{ + /// Takes a synchronous validation callback, applies the validation callback over its wrapped + /// Iterator of [`ValidatorReference`]s, and returns a [`FinalizedValidator`] that + /// holds the result of each item. + pub fn run( + self, + validation_callback: &mut impl FnMut(&OutPoint) -> Result, + ) -> Result< + FinalizedValidator< + impl IntoIterator>, + OutPoint, + InputSeenTag, + >, + ImplementationError, + > { + self.0.run(validation_callback) + } + + /// Takes an asynchronous validation callback, applies the validation callback over its + /// wrapped Iterator of [`ValidatorReference`]s, and returns a [`FinalizedValidator`] + /// that holds the result of each item. + pub async fn run_async( + self, + validation_callback: F, + ) -> Result< + FinalizedValidator< + impl IntoIterator>, + OutPoint, + InputSeenTag, + >, + ImplementationError, + > + where + F: FnMut(&OutPoint) -> Fut, + Fut: std::future::Future>, + { + self.0.run_async(validation_callback).await + } +} + +/// Runs validation for checking which outputs are owned on original PSBT +/// +/// [`OutputsOwnedValidator::run`] takes a validation callback, applies it +/// over each item in the wrapped Iterator, and returns a +/// [`FinalizedValidator`] that holds the result +/// of each item. This is [`OutputsOwnedValidator`]'s only purpose. +pub struct OutputsOwnedValidator(Validator) +where + I: Iterator>; + +impl OutputsOwnedValidator>> { + fn new( + psbt: &Psbt, + ) -> OutputsOwnedValidator>> { + let output_script_refs: Vec> = psbt + .unsigned_tx + .output + .iter() + .enumerate() + .map(|(index, output)| ValidatorReference { + index, + value: output.script_pubkey.to_owned(), + }) + .collect(); + OutputsOwnedValidator(Validator::new( + output_script_refs.into_iter(), + psbt.unsigned_tx.compute_ntxid(), + )) + } +} + +impl OutputsOwnedValidator +where + I: Iterator>, +{ + /// Takes a synchronous validation callback, applies the validation callback over its wrapped + /// Iterator of [`ValidatorReference`]s, and returns a [`FinalizedValidator`] that + /// holds the result of each item. + pub fn run( + self, + validation_callback: &mut impl FnMut(&ScriptBuf) -> Result, + ) -> Result< + FinalizedValidator< + impl IntoIterator>, + ScriptBuf, + OutputOwnedTag, + >, + ImplementationError, + > { + self.0.run(validation_callback) + } + + /// Takes an asynchronous validation callback, applies the validation callback over its + /// wrapped Iterator of [`ValidatorReference`]s, and returns a [`FinalizedValidator`] + /// that holds the result of each item. + pub async fn run_async( + self, + validation_callback: F, + ) -> Result< + FinalizedValidator< + impl IntoIterator>, + ScriptBuf, + OutputOwnedTag, + >, + ImplementationError, + > + where + F: FnMut(&ScriptBuf) -> Fut, + Fut: std::future::Future>, + { + self.0.run_async(validation_callback).await + } +} + +/// Used to return validation results for a list of items +/// +/// The only ways to create a [`FinalizedValidator`] is by calling `run` or +/// `run_async` on [`InputsOwnedValidator`], [`InputsSeenValidator`] or +/// [`OutputsOwnedValidator`] +pub struct FinalizedValidator(I, sha256d::Hash) +where + I: IntoIterator>, + R: Clone, + T: ValidatorReferenceTag; + +impl FinalizedValidator +where + I: IntoIterator>, + R: Clone, + T: ValidatorReferenceTag, +{ + /// Takes an expected count and expected ntxid and verifies the [`FinalizedValidator`] + /// has all indexes accounted for, matches the expected count, matches the expected + /// ntxid, and returns an Iterator of [`ValidatorReference`]s that have a positive + /// result. + pub(crate) fn verify( + self, + expected_count: usize, + expected_ntxid: sha256d::Hash, + ) -> Result>, ImplementationError> { + if expected_ntxid != self.1 { + return Err(ImplementationError::from( + "Validation error: encountered unexpected identifier", + )); + } + let refs = self.0.into_iter(); + let mut running_index: usize = 0; + let positives_result: Result, ImplementationError> = refs + .enumerate() + .filter_map(|(index, tagged_ref)| { + running_index = index; + if index != tagged_ref.reference.get_index() { + return Some(Err(ImplementationError::from( + "Validation error: encountered unexpected reference index", + ))); + }; + match tagged_ref.is_true() { + true => Some(Ok(tagged_ref.reference)), + false => None, + } + }) + .collect(); + if expected_count != running_index + 1 { + return Err(ImplementationError::from( + "Validation error: encountered unexpected number of references", + )); + } + Ok(positives_result?.into_iter()) + } +} + /// Validate the payload of a Payjoin request for PSBT and Params sanity pub(crate) fn parse_payload( base64: &str, @@ -255,12 +669,12 @@ 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) - fn prepare_psbt(self, processed_psbt: Psbt) -> Psbt { + fn prepare_psbt(self, processed_psbt: &Psbt) -> Psbt { tracing::trace!("Original PSBT from callback: {processed_psbt:#?}"); // Create a new PSBT and copy only the allowed fields let mut filtered_psbt = Psbt { - unsigned_tx: processed_psbt.unsigned_tx, + unsigned_tx: processed_psbt.unsigned_tx.clone(), version: processed_psbt.version, xpub: BTreeMap::new(), proprietary: BTreeMap::new(), @@ -313,33 +727,33 @@ impl PsbtContext { sender_input_indexes } - /// 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. - fn finalize_proposal( - self, - wallet_process_psbt: impl Fn(&Psbt) -> Result, - ) -> Result { + /// Return the payjoin PSBT that is ready for signing + fn payjoin_psbt_without_sender_signatures(&self) -> Psbt { let mut psbt = self.payjoin_psbt.clone(); // Remove now-invalid sender signatures before applying the receiver signatures for i in self.sender_input_indexes() { - tracing::trace!("Clearing sender input {i}"); psbt.inputs[i].final_script_sig = None; psbt.inputs[i].final_script_witness = None; psbt.inputs[i].tap_key_sig = None; } - let finalized_psbt = wallet_process_psbt(&psbt)?; + psbt + } + + /// 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. Validate that signed psbt contains expected inputs and outputs + /// 2. Prepare the psbt to be sent to sender + fn finalize_proposal(self, signed_psbt: &Psbt) -> Result { let expected_ntxid = self.payjoin_psbt.unsigned_tx.compute_ntxid(); - let actual_ntxid = finalized_psbt.unsigned_tx.compute_ntxid(); + let actual_ntxid = signed_psbt.unsigned_tx.compute_ntxid(); if expected_ntxid != actual_ntxid { return Err(ImplementationError::from( format!("Ntxid mismatch: expected {expected_ntxid}, got {actual_ntxid}").as_str(), )); } - let payjoin_proposal = self.prepare_psbt(finalized_psbt); + let payjoin_proposal = self.prepare_psbt(signed_psbt); Ok(payjoin_proposal) } } @@ -363,6 +777,17 @@ impl OriginalPayload { &self, min_fee_rate: Option, can_broadcast: impl Fn(&bitcoin::Transaction) -> Result, + ) -> Result<(), Error> { + self.process_broadcast_suitability_result( + min_fee_rate, + can_broadcast(&self.psbt.clone().extract_tx_unchecked_fee_rate())?, + ) + } + + pub fn process_broadcast_suitability_result( + &self, + min_fee_rate: Option, + can_broadcast: bool, ) -> Result<(), Error> { let original_psbt_fee_rate = self.psbt_fee_rate()?; if let Some(min_fee_rate) = min_fee_rate { @@ -374,81 +799,180 @@ impl OriginalPayload { .into()); } } - if can_broadcast(&self.psbt.clone().extract_tx_unchecked_fee_rate()) - .map_err(Error::Implementation)? - { + if can_broadcast { Ok(()) } else { Err(InternalPayloadError::OriginalPsbtNotBroadcastable.into()) } } - /// Check that the original PSBT has no receiver-owned inputs. + /// 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<(), Error> { - let mut err: Result<(), Error> = Ok(()); - if let Some(e) = self - .psbt - .input_pairs() - .scan(&mut err, |err, input| match input.previous_txout() { - Ok(txout) => Some(txout.script_pubkey.to_owned()), - Err(e) => { - **err = Err(InternalPayloadError::PrevTxOut(e).into()); - None - } - }) - .find_map(|script| match is_owned(&script) { - Ok(false) => None, - Ok(true) => Some(InternalPayloadError::InputOwned(script).into()), - Err(e) => Some(Error::Implementation(e)), - }) - { - return Err(e); + let validator = InputsOwnedValidator::new(&self.psbt)?; + let finalized_validator = + validator.run(&mut |script_buf| is_owned(script_buf.as_script()))?; + self.process_inputs_owned_validator(finalized_validator) + } + + /// Get a [`InputsOwnedValidator`] for checking original PSBT has no receiver owend inputs. + /// + /// An attacker can try to spend the receiver's own inputs. This check prevents that. + /// + /// Returns a [`InputsOwnedValidator`] for checking whether the inputs are receiver owned. + /// Run the [`InputsOwnedValidator`] using [`InputsOwnedValidator::run`] or + /// [`InputsOwnedValidator::run_async`] and submit the resulting [`FinalizedValidator`] to + /// [`OriginalPayload::process_inputs_owned_validator`]. + pub fn get_inputs_owned_validator( + &self, + ) -> Result>>, Error> + { + InputsOwnedValidator::new(&self.psbt) + } + + /// Processes a [`FinalizedValidator`] by verifying it and ensuring original PSBT has no + /// receiver owned inputs. + /// + /// An attacker can try to spend the receiver's own inputs. This check prevents that. + /// + /// Takes a [`FinalizedValidator`], verifies it is valid for the original PSBT, and checks + /// that none of the inputs tested positive for being receiver owned. Use + /// [`InputsOwnedValidator::run`] + /// or [`InputsOwnedValidator::run_async`] to get a [`FinalizedValidator`]. + pub fn process_inputs_owned_validator( + &self, + finalized_validator: FinalizedValidator< + impl IntoIterator>, + ScriptBuf, + InputOwnedTag, + >, + ) -> Result<(), Error> { + let mut owned_input_scripts = finalized_validator + .verify(self.psbt.inputs.len(), self.psbt.unsigned_tx.compute_ntxid())? + .map(|tagged_ref| tagged_ref.get_value()); + match owned_input_scripts.next() { + Some(input_script) => Err(InternalPayloadError::InputOwned(input_script).into()), + None => Ok(()), } - err?; - Ok(()) } + /// Check that the original PSBT has no receiver seen inputs. + /// + /// An attacker can try to dox the receivers addresses by sending the same original + /// PSBT multiple times. This check prevents that. pub fn check_no_inputs_seen_before( &self, is_known: &mut impl FnMut(&OutPoint) -> Result, ) -> Result<(), Error> { - self.psbt.input_pairs().try_for_each(|input| { - match is_known(&input.txin.previous_output) { - Ok(false) => Ok::<(), Error>(()), - Ok(true) => { - tracing::warn!("Request contains an input we've seen before: {}. Preventing possible probing attack.", input.txin.previous_output); - Err(InternalPayloadError::InputSeen(input.txin.previous_output))? - }, - Err(e) => Err(Error::Implementation(e))?, + let validator = InputsSeenValidator::new(&self.psbt); + let finalized_validator = validator.run(is_known)?; + self.process_inputs_seen_validator(finalized_validator) + } + + /// Get a [`InputsSeenValidator`] for checking original PSBT has no receiver seen inputs. + /// + /// An attacker can try to dox the receivers addresses by sending the same original + /// PSBT multiple times. This check prevents that. + /// + /// Returns a [`InputsSeenValidator`] for checking whether the inputs have been seen by the receiver. + /// Run the [`InputsSeenValidator`] using [`InputsSeenValidator::run`] or + /// [`InputsSeenValidator::run_async`] and submit the resulting [`FinalizedValidator`] to + /// [`OriginalPayload::process_inputs_seen_validator`]. + pub fn get_inputs_seen_validator( + &self, + ) -> InputsSeenValidator>> { + InputsSeenValidator::new(&self.psbt) + } + + /// Processes a [`FinalizedValidator`] by verifying it and ensuring original PSBT has no + /// receiver seen inputs. + /// + /// An attacker can try to dox the receivers addresses by sending the same original + /// PSBT multiple times. This check prevents that. + /// + /// Takes a [`FinalizedValidator`], verifies it is valid for the original PSBT, and checks + /// that none of the inputs tested positive for being seen by the receiver. Get a + /// [`InputsSeenValidator`] + /// from [`OriginalPayload::get_inputs_seen_validator`] and use [`InputsSeenValidator::run`] + /// or [`InputsSeenValidator::run_async`] to get a [`FinalizedValidator`]. + pub fn process_inputs_seen_validator( + &self, + finalized_validator: FinalizedValidator< + impl IntoIterator>, + OutPoint, + InputSeenTag, + >, + ) -> Result<(), Error> { + let mut seen_input_outpoints = finalized_validator + .verify(self.psbt.inputs.len(), self.psbt.unsigned_tx.compute_ntxid())? + .map(|tagged_ref| tagged_ref.get_value()); + match seen_input_outpoints.next() { + Some(input_outpoint) => { + tracing::warn!("Request contains an input we've seen before: {}. Preventing possible probing attack.", input_outpoint); + Err(InternalPayloadError::InputSeen(input_outpoint))? } - })?; - Ok(()) + None => Ok(()), + } } + /// Check that the original PSBT has receiver owned outputs. + /// + /// An attacker can try to steal funds from the receiver inputs added to the payjoin + /// by not including any receiver owned outputs. This check prevents that. pub fn identify_receiver_outputs( - self, + &self, is_receiver_output: &mut impl FnMut(&Script) -> Result, ) -> Result { - let owned_vouts: Vec = self - .psbt - .unsigned_tx - .output - .iter() - .enumerate() - .filter_map(|(vout, txo)| match is_receiver_output(&txo.script_pubkey) { - Ok(true) => Some(Ok(vout)), - Ok(false) => None, - Err(e) => Some(Err(e)), - }) - .collect::, _>>() - .map_err(Error::Implementation)?; + let validator = OutputsOwnedValidator::new(&self.psbt); + let finalized_validator = + validator.run(&mut |script_buf| is_receiver_output(script_buf.as_script()))?; + self.process_outputs_owned_validator(finalized_validator) + } + + /// Get a [`OutputsOwnedValidator`] for checking original PSBT has receiver owned outputs. + /// + /// An attacker can try to steal funds from the receiver inputs added to the payjoin + /// by not including any receiver owned outputs. This check prevents that. + /// + /// Returns a [`OutputsOwnedValidator`] for checking whether the receiver owns any of the outputs. + /// Run the [`OutputsOwnedValidator`] using [`OutputsOwnedValidator::run`] or + /// [`OutputsOwnedValidator::run_async`] and submit the resulting [`FinalizedValidator`] to + /// [`OriginalPayload::process_outputs_owned_validator`]. + pub fn get_outputs_owned_validator( + &self, + ) -> OutputsOwnedValidator>> { + OutputsOwnedValidator::new(&self.psbt) + } - if owned_vouts.is_empty() { + /// Processes a [`FinalizedValidator`] by verifying it and ensuring original PSBT has + /// receiver owned outputs. + /// + /// An attacker can try to steal funds from the receiver inputs added to the payjoin + /// by not including any receiver owned outputs. This check prevents that. + /// + /// Takes a [`FinalizedValidator`], verifies it is valid for the original PSBT, and checks + /// that at least one output tested positive for being receiver owned. Get a + /// [`OutputsOwnedValidator`] from [`OriginalPayload::get_outputs_owned_validator`] and + /// use [`OutputsOwnedValidator::run`] or [`OutputsOwnedValidator::run_async`] to get a + /// [`FinalizedValidator`]. + pub fn process_outputs_owned_validator( + &self, + finalized_validator: FinalizedValidator< + impl IntoIterator>, + ScriptBuf, + OutputOwnedTag, + >, + ) -> Result { + let owned_output_indexes: Vec = finalized_validator + .verify(self.psbt.outputs.len(), self.psbt.unsigned_tx.compute_ntxid())? + .map(|tagged_ref| tagged_ref.get_index()) + .collect(); + + if owned_output_indexes.is_empty() { return Err(InternalPayloadError::MissingPayment.into()); } @@ -457,12 +981,12 @@ impl OriginalPayload { // If the additional fee output index specified by the sender is pointing to a receiver output, // the receiver should ignore the parameter. // https://github.com/bitcoin/bips/blob/master/bip-0078.mediawiki#optional-parameters - if owned_vouts.contains(&additional_fee_output_index) { + if owned_output_indexes.contains(&additional_fee_output_index) { params.additional_fee_contribution = None; } } let original_payload = OriginalPayload { params, ..self.clone() }; - Ok(common::WantsOutputs::new(original_payload, owned_vouts)) + Ok(common::WantsOutputs::new(original_payload, owned_output_indexes)) } } @@ -858,4 +1382,141 @@ pub(crate) mod tests { assert_eq!(wants_outputs.owned_vouts, vec![0, 1]); assert_eq!(wants_outputs.params.additional_fee_contribution, None); } + + fn get_count(mut iterator: impl Iterator) -> usize { + let mut count: usize = 0; + while iterator.next().is_some() { + count += 1; + } + count + } + + #[test] + fn test_validator_run_and_verify() { + let psbt = PARSED_ORIGINAL_PSBT.clone(); + let ntxid = psbt.unsigned_tx.compute_ntxid(); + let input_count = psbt.inputs.len(); + let output_count = psbt.unsigned_tx.output.len(); + + let inputs_owned_finalized = + InputsOwnedValidator::new(&psbt).unwrap().run(&mut |_script| Ok(true)).unwrap(); + let inputs_seen_finalized = + InputsSeenValidator::new(&psbt).run(&mut |_outpoint| Ok(true)).unwrap(); + let outputs_owned_finalized = + OutputsOwnedValidator::new(&psbt).run(&mut |_script| Ok(true)).unwrap(); + + let inputs_owned_verified = inputs_owned_finalized.verify(input_count, ntxid); + let inputs_seen_verified = inputs_seen_finalized.verify(input_count, ntxid); + let outputs_owned_verified = outputs_owned_finalized.verify(output_count, ntxid); + assert!(inputs_owned_verified.is_ok()); + assert!(inputs_seen_verified.is_ok()); + assert!(outputs_owned_verified.is_ok()); + assert_eq!(get_count(inputs_owned_verified.unwrap()), input_count); + assert_eq!(get_count(inputs_seen_verified.unwrap()), input_count); + assert_eq!(get_count(outputs_owned_verified.unwrap()), output_count); + } + + #[tokio::test] + async fn test_validator_run_async_and_verify() { + let psbt = PARSED_ORIGINAL_PSBT.clone(); + let ntxid = psbt.unsigned_tx.compute_ntxid(); + let input_count = psbt.inputs.len(); + let output_count = psbt.unsigned_tx.output.len(); + + let inputs_owned_finalized = InputsOwnedValidator::new(&psbt) + .unwrap() + .run_async(|_script| async { Ok(false) }) + .await + .unwrap(); + let inputs_seen_finalized = InputsSeenValidator::new(&psbt) + .run_async(|_outpoint| async { Ok(false) }) + .await + .unwrap(); + let outputs_owned_finalized = OutputsOwnedValidator::new(&psbt) + .run_async(|_script| async { Ok(false) }) + .await + .unwrap(); + + let inputs_owned_verified = inputs_owned_finalized.verify(input_count, ntxid); + let inputs_seen_verified = inputs_seen_finalized.verify(input_count, ntxid); + let outputs_owned_verified = outputs_owned_finalized.verify(output_count, ntxid); + assert!(inputs_owned_verified.is_ok()); + assert!(inputs_seen_verified.is_ok()); + assert!(outputs_owned_verified.is_ok()); + assert_eq!(get_count(inputs_owned_verified.unwrap()), 0); + assert_eq!(get_count(inputs_seen_verified.unwrap()), 0); + assert_eq!(get_count(outputs_owned_verified.unwrap()), 0); + } + + fn extract_validator_error(res: Result) -> Option { + match res { + Ok(_) => None, + Err(e) => Some(e.to_string()), + } + } + + #[test] + fn test_validator_run_returns_callback_error() { + let psbt = PARSED_ORIGINAL_PSBT.clone(); + let callback_error_msg = "validator callback error"; + + let inputs_owned_result = InputsOwnedValidator::new(&psbt) + .unwrap() + .run(&mut |_script| Err(ImplementationError::from(callback_error_msg))); + let inputs_seen_result = InputsSeenValidator::new(&psbt) + .run(&mut |_outpoint| Err(ImplementationError::from(callback_error_msg))); + let outputs_owned_result = OutputsOwnedValidator::new(&psbt) + .run(&mut |_script| Err(ImplementationError::from(callback_error_msg))); + + assert_eq!(extract_validator_error(inputs_owned_result).unwrap(), callback_error_msg); + assert_eq!(extract_validator_error(inputs_seen_result).unwrap(), callback_error_msg); + assert_eq!(extract_validator_error(outputs_owned_result).unwrap(), callback_error_msg); + } + + #[tokio::test] + async fn test_validator_run_async_returns_callback_error() { + let psbt = PARSED_ORIGINAL_PSBT.clone(); + let callback_error_msg = "validator callback error"; + + let inputs_owned_result = InputsOwnedValidator::new(&psbt) + .unwrap() + .run_async(|_script| async { Err(ImplementationError::from(callback_error_msg)) }) + .await; + let inputs_seen_result = InputsSeenValidator::new(&psbt) + .run_async(|_outpoint| async { Err(ImplementationError::from(callback_error_msg)) }) + .await; + let outputs_owned_result = OutputsOwnedValidator::new(&psbt) + .run_async(|_script| async { Err(ImplementationError::from(callback_error_msg)) }) + .await; + + assert_eq!(extract_validator_error(inputs_owned_result).unwrap(), callback_error_msg); + assert_eq!(extract_validator_error(inputs_seen_result).unwrap(), callback_error_msg); + assert_eq!(extract_validator_error(outputs_owned_result).unwrap(), callback_error_msg); + } + + #[test] + fn test_finalized_validator_verify_errors() { + let psbt = PARSED_ORIGINAL_PSBT.clone(); + let ntxid = psbt.unsigned_tx.compute_ntxid(); + let input_count = psbt.inputs.len(); + + // Wrong identifier + let wrong_ntxid = sha256d::Hash::hash(b"wrong identifier"); + let finalized = + InputsOwnedValidator::new(&psbt).unwrap().run(&mut |_script| Ok(true)).unwrap(); + let verify_result = finalized.verify(input_count, wrong_ntxid); + assert_eq!( + extract_validator_error(verify_result).unwrap(), + "Validation error: encountered unexpected identifier", + ); + + // Wrong count + let finalized = + InputsOwnedValidator::new(&psbt).unwrap().run(&mut |_script| Ok(true)).unwrap(); + let verify_result = finalized.verify(input_count + 1, ntxid); + assert_eq!( + extract_validator_error(verify_result).unwrap(), + "Validation error: encountered unexpected number of references" + ); + } } diff --git a/payjoin/src/core/receive/v1/mod.rs b/payjoin/src/core/receive/v1/mod.rs index 7c2e937c3..7f60989d9 100644 --- a/payjoin/src/core/receive/v1/mod.rs +++ b/payjoin/src/core/receive/v1/mod.rs @@ -32,6 +32,7 @@ //! but request reuse makes correlation trivial for the relay. mod error; + use bitcoin::OutPoint; pub(crate) use error::InternalRequestError; pub use error::RequestError; @@ -79,14 +80,19 @@ impl UncheckedOriginalPayload { /// 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. +/// for each visit) payment receiver. /// /// 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. +/// +/// If implementing a non-interactive payment receiver, there are two options to check that the proposal is +/// actually broadcastable (and, optionally, whether the fee rate is above the minimum limit you have set). +/// The synchronous, blocking option is to call [`Self::check_broadcast_suitability`]. The asynchronous, +/// non-blocking option is call [`Self::extract_tx_to_check_broadcast_suitability`] to obtain the original tx, +/// validate broadcastibility on the caller's side, and subsequently call +/// [`Self::process_broadcast_suitability_result`] to return the result. 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. #[derive(Debug, Clone)] pub struct UncheckedOriginalPayload { original: OriginalPayload, @@ -95,6 +101,10 @@ pub struct UncheckedOriginalPayload { impl UncheckedOriginalPayload { /// Checks that the original PSBT in the proposal can be broadcasted. /// + /// The can_broadcast callback taken here must be a synchronous, blocking one, for + /// asynchronous, non-blocking option use [`Self::extract_tx_to_check_broadcast_suitability`] + /// in conjunction with [`Self::process_broadcast_suitability_result`]. + /// /// 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 @@ -109,7 +119,43 @@ impl UncheckedOriginalPayload { min_fee_rate: Option, can_broadcast: impl Fn(&bitcoin::Transaction) -> Result, ) -> Result { - self.original.check_broadcast_suitability(min_fee_rate, can_broadcast)?; + let tx = self.extract_tx_to_check_broadcast_suitability(); + self.process_broadcast_suitability_result(min_fee_rate, can_broadcast(&tx)?) + } + + /// Extracts the original PSBT so caller can check that the proposal can be broadcasted. + /// + /// Result of the broadcastibility check should then be returned to + /// [`Self::process_broadcast_suitability_result`]. + /// + /// 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,...}`. + pub fn extract_tx_to_check_broadcast_suitability(&self) -> bitcoin::Transaction { + self.original.psbt.clone().extract_tx_unchecked_fee_rate() + } + + /// Processes the result of whether the original PSBT in the proposal can be broadcasted. + /// + /// Call [`Self::extract_tx_to_check_broadcast_suitability`] first to acquire the tx + /// to be checked for broadcastibility. + /// + /// 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 process_broadcast_suitability_result( + self, + min_fee_rate: Option, + is_broadcast_suitable: bool, + ) -> Result { + self.original.process_broadcast_suitability_result(min_fee_rate, is_broadcast_suitable)?; Ok(MaybeInputsOwned { original: self.original }) } @@ -128,7 +174,11 @@ impl UncheckedOriginalPayload { /// 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. +/// There are two options for proceeding to the next typestate. The synchronous, +/// blocking option is to call [`Self::check_inputs_not_owned`]. The asynchronous, +/// non-blocking option is call [`Self::get_inputs_owned_validator`] to obtain +/// an inputs owned [`InputsOwnedValidator`], run the validator to obtain a [`FinalizedValidator`], +/// and return the [`FinalizedValidator`] to [`Self::process_inputs_owned_validator`]. #[derive(Debug, Clone)] pub struct MaybeInputsOwned { pub(crate) original: OriginalPayload, @@ -144,21 +194,65 @@ impl MaybeInputsOwned { self.original.psbt.clone().extract_tx_unchecked_fee_rate() } - /// Check that the original PSBT has no receiver-owned inputs. + /// Check that the original PSBT has no receiver owned inputs. + /// + /// The is_owned callback taken here must be a synchronous, blocking one. For + /// asynchronous, non-blocking option use [`Self::get_inputs_owned_validator`] + /// in conjunction with [`Self::process_inputs_owned_validator`]. /// /// 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)?; + let validator = self.get_inputs_owned_validator()?; + let finalized_validator = + validator.run(&mut |script_buf| is_owned(script_buf.as_script()))?; + self.process_inputs_owned_validator(finalized_validator) + } + + /// Get an inputs owned [`InputsOwnedValidator`] that will check that the original PSBT + /// has no receiver owned inputs. + /// + /// Once the [`InputsOwnedValidator`] has been run it will produce a [`FinalizedValidator`] + /// which should be returned to [`Self::process_inputs_owned_validator`]. + /// + /// An attacker can try to spend the receiver's own inputs. This check prevents that. + pub fn get_inputs_owned_validator( + &self, + ) -> Result>>, Error> + { + self.original.get_inputs_owned_validator() + } + + /// Process inputs owned [`FinalizedValidator`] to confirm that the original + /// PSBT has no receiver owned inputs. + /// + /// This takes a [`FinalizedValidator`] which should be produced by running + /// the [`InputsOwnedValidator`] returned from [`Self::get_inputs_owned_validator`]. + /// + /// An attacker can try to spend the receiver's own inputs. This check prevents that. + pub fn process_inputs_owned_validator( + self, + finalized_validator: FinalizedValidator< + impl IntoIterator>, + ScriptBuf, + InputOwnedTag, + >, + ) -> Result { + self.original.process_inputs_owned_validator(finalized_validator)?; Ok(MaybeInputsSeen { original: self.original }) } } -/// Typestate to check that the original PSBT has no inputs that the receiver has seen before. +/// 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. +/// There are two options for proceeding to the next typestate. The synchronous, +/// blocking option is to call [`Self::check_no_inputs_seen_before`]. The asynchronous, +/// non-blocking option is call [`Self::get_inputs_seen_validator`] to obtain +/// an inputs seen [`InputsSeenValidator`], run the validator to obtain a [`FinalizedValidator`], +/// and return the [`FinalizedValidator`] to [`Self::process_inputs_seen_validator`]. #[derive(Debug, Clone)] pub struct MaybeInputsSeen { original: OriginalPayload, @@ -166,6 +260,10 @@ pub struct MaybeInputsSeen { impl MaybeInputsSeen { /// Check that the receiver has never seen the inputs in the original proposal before. /// + /// The is_known callback taken here must be a synchronous, blocking one. For + /// asynchronous, non-blocking option use [`Self::get_inputs_seen_validator`] + /// in conjunction with [`Self::process_inputs_seen_validator`]. + /// /// 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 @@ -176,7 +274,50 @@ impl MaybeInputsSeen { self, is_known: &mut impl FnMut(&OutPoint) -> Result, ) -> Result { - self.original.check_no_inputs_seen_before(is_known)?; + let validator = self.get_inputs_seen_validator(); + let finalized_validator = validator.run(is_known)?; + self.process_inputs_seen_validator(finalized_validator) + } + + /// Get an inputs seen [`InputsSeenValidator`] that will check that the original PSBT + /// does not contain inputs that the receiver has seen before. + /// + /// Once the [`InputsSeenValidator`] has been run it will produce a [`FinalizedValidator`] + /// which should be returned to [`Self::process_inputs_seen_validator`]. + /// + /// 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 get_inputs_seen_validator( + &self, + ) -> InputsSeenValidator>> { + self.original.get_inputs_seen_validator() + } + + /// Process inputs seen [`FinalizedValidator`] to confirm that the original + /// PSBT does not contain inputs that the receiver has seen before. + /// + /// This takes a [`FinalizedValidator`] which should be produced by running + /// the [`InputsSeenValidator`] returned from [`Self::get_inputs_seen_validator`]. + /// + /// 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 process_inputs_seen_validator( + self, + finalized_validator: FinalizedValidator< + impl IntoIterator>, + OutPoint, + InputSeenTag, + >, + ) -> Result { + self.original.process_inputs_seen_validator(finalized_validator)?; Ok(OutputsUnknown { original: self.original }) } } @@ -186,7 +327,11 @@ impl MaybeInputsSeen { /// The receiver should only accept the original PSBTs from the sender if it actually sends them /// money. /// -/// Call [`Self::identify_receiver_outputs`] to proceed. +/// There are two options for proceeding to the next typestate. The synchronous, +/// blocking option is to call [`Self::identify_receiver_outputs`]. The asynchronous, +/// non-blocking option is call [`Self::get_outputs_owned_validator`] to obtain +/// an outputs owned [`OutputsOwnedValidator`], run the validator to obtain a [`FinalizedValidator`], +/// and return the [`FinalizedValidator`] to [`Self::process_outputs_owned_validator`]. #[derive(Debug, Clone)] pub struct OutputsUnknown { original: OriginalPayload, @@ -196,6 +341,10 @@ impl OutputsUnknown { /// Validates whether the original PSBT contains outputs which pay to the receiver and only /// then proceeds to the next typestate. /// + /// The is_receiver_output callback taken here must be a synchronous, blocking one. For + /// asynchronous, non-blocking option use [`Self::get_outputs_owned_validator`] + /// in conjunction with [`Self::process_outputs_owned_validator`]. + /// /// 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 @@ -205,10 +354,58 @@ impl OutputsUnknown { /// outputs. #[cfg_attr(not(feature = "v1"), allow(dead_code))] pub fn identify_receiver_outputs( - self, + &self, is_receiver_output: &mut impl FnMut(&Script) -> Result, ) -> Result { - self.original.identify_receiver_outputs(is_receiver_output) + let validator = self.get_outputs_owned_validator(); + let finalized_validator = + validator.run(&mut |script_buf| is_receiver_output(script_buf.as_script()))?; + self.process_outputs_owned_validator(finalized_validator) + } + + /// Get an outputs owned [`OutputsOwnedValidator`] that will check that the original PSBT + /// contains outputs which pay the receiver and only then proceeds to the next typestate. + /// + /// Once the [`OutputsOwnedValidator`] has been run it will produce a [`FinalizedValidator`] + /// which should be returned to [`Self::process_outputs_owned_validator`]. + /// + /// 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 get_outputs_owned_validator( + &self, + ) -> OutputsOwnedValidator>> { + self.original.get_outputs_owned_validator() + } + + /// Process outputs owned [`FinalizedValidator`] to confirm that the original PSBT + /// contains outputs which pay the receiver and only then proceeds to the next typestate. + /// + /// This takes a [`FinalizedValidator`] which should be produced by running + /// the [`OutputsOwnedValidator`] returned from [`Self::get_outputs_owned_validator`]. + /// + /// 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 process_outputs_owned_validator( + &self, + finalized_validator: FinalizedValidator< + impl IntoIterator>, + ScriptBuf, + OutputOwnedTag, + >, + ) -> Result { + self.original.process_outputs_owned_validator(finalized_validator) } } @@ -275,7 +472,11 @@ impl crate::receive::common::WantsFeeRange { /// 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`]. +/// There are two options for proceeding to the finalized [`PayjoinProposal`] +/// typestate. The synchronous, blocking option is to call [`Self::finalize_proposal`]. +/// The asynchronous, non-blocking option is call [`Self::psbt_to_sign`] to +/// extract the psbt to be signed and [`Self::finalize_signed_proposal`] to return the +/// receiver signed proposal PSBT. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ProvisionalProposal { psbt_context: PsbtContext, @@ -285,6 +486,10 @@ 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. /// + /// The wallet_process_psbt callback taken here must be a synchronous, blocking one. For + /// asynchronous, non-blocking option use [`Self::psbt_to_sign`] + /// in conjunction with [`Self::finalize_signed_proposal`]. + /// /// 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. @@ -292,19 +497,30 @@ impl ProvisionalProposal { self, wallet_process_psbt: impl Fn(&Psbt) -> Result, ) -> Result { - let finalized_psbt = self - .psbt_context - .finalize_proposal(wallet_process_psbt) - .map_err(|e| Error::Implementation(ImplementationError::new(e)))?; - Ok(PayjoinProposal { payjoin_psbt: finalized_psbt }) + let psbt = self.psbt_to_sign(); + let signed_psbt = wallet_process_psbt(&psbt)?; + self.finalize_signed_proposal(&signed_psbt) + } + + /// Returns the Payjoin proposal PSBT to be signed by receiver before being finalized + /// and sent to sender. + /// + /// After this payjoin proposal PSBT is signed by receiver it should then be returned to + /// [`Self::finalize_signed_proposal`] to be finalized. + pub fn psbt_to_sign(&self) -> Psbt { + self.psbt_context.payjoin_psbt_without_sender_signatures() } - /// The Payjoin proposal PSBT that the receiver needs to sign + /// Finalizes the Payjoin proposal into a PSBT which the sender will find acceptable before + /// they sign the transaction and broadcast it to the network. /// - /// In some applications the entity that progresses the typestate - /// is different from the entity that has access to the private keys, - /// so the PSBT to sign must be accessible to such implementers. - pub fn psbt_to_sign(&self) -> Psbt { self.psbt_context.payjoin_psbt.clone() } + /// This takes a receiver signed PSBT payjoin proposal and finalizes it for broadcast to + /// the sender. Use [`Self::psbt_to_sign`] to obtain the payjoin proposal's unsigned + /// PSBT for receiver to sign and return here. + pub fn finalize_signed_proposal(self, signed_psbt: &Psbt) -> Result { + let finalized_psbt = self.psbt_context.finalize_proposal(signed_psbt)?; + Ok(PayjoinProposal { payjoin_psbt: finalized_psbt }) + } } /// A finalized Payjoin proposal, complete with fees and receiver signatures, that the sender diff --git a/payjoin/src/core/receive/v2/mod.rs b/payjoin/src/core/receive/v2/mod.rs index 307cd6a50..c3a3f6edd 100644 --- a/payjoin/src/core/receive/v2/mod.rs +++ b/payjoin/src/core/receive/v2/mod.rs @@ -30,7 +30,7 @@ use std::time::Duration; use bitcoin::hashes::{sha256, Hash}; use bitcoin::psbt::Psbt; -use bitcoin::{Address, Amount, FeeRate, OutPoint, Script, TxOut, Txid}; +use bitcoin::{Address, Amount, FeeRate, OutPoint, Script, ScriptBuf, TxOut, Txid}; pub(crate) use error::InternalSessionError; pub use error::SessionError; use serde::de::Deserializer; @@ -57,7 +57,11 @@ use crate::persist::{ MaybeFatalOrSuccessTransition, MaybeFatalTransition, MaybeFatalTransitionWithNoResults, MaybeSuccessTransition, MaybeTransientTransition, NextStateTransition, }; -use crate::receive::{parse_payload, InputPair, OriginalPayload, PsbtContext}; +use crate::receive::{ + parse_payload, FinalizedValidator, InputOwnedTag, InputPair, InputSeenTag, + InputsOwnedValidator, InputsSeenValidator, OriginalPayload, OutputOwnedTag, + OutputsOwnedValidator, PsbtContext, TaggedValidatorReference, ValidatorReference, +}; use crate::time::Time; use crate::uri::ShortId; use crate::{ImplementationError, IntoUrl, IntoUrlError, Request, Version}; @@ -541,21 +545,30 @@ pub struct UncheckedOriginalPayload { /// 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 [`Receiver::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. +/// for each visit) payment receiver. /// /// If you are implementing an interactive payment receiver, then such checks are not necessary, and you /// can go ahead with calling [`Receiver::assume_interactive_receiver`] to move on to the next typestate. +/// +/// If implementing a non-interactive payment receiver, there are two options to check that the proposal is +/// actually broadcastable (and, optionally, whether the fee rate is above the minimum limit you have set). +/// The synchronous, blocking option is to call [`Receiver::check_broadcast_suitability`]. The asynchronous, +/// non-blocking option is call [`Receiver::extract_tx_to_check_broadcast_suitability`] to obtain the original tx, +/// validate broadcastibility on the caller's side, and subsequently call +/// [`Receiver::process_broadcast_suitability_result`] to return the result. 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. impl Receiver { /// Checks that the original PSBT in the proposal can be broadcasted. /// + /// The can_broadcast callback taken here must be a synchronous, blocking one, for + /// asynchronous, non-blocking option use [`Receiver::extract_tx_to_check_broadcast_suitability`] + /// in conjunction with [`Receiver::process_broadcast_suitability_result`]. + /// /// 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` RPC call returning `{"allowed": true,...}`. + /// `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 @@ -571,7 +584,56 @@ impl Receiver { Error, Receiver, > { - match self.state.original.check_broadcast_suitability(min_fee_rate, can_broadcast) { + let tx = self.extract_tx_to_check_broadcast_suitability(); + match can_broadcast(&tx) { + Ok(is_broadcast_suitable) => + self.process_broadcast_suitability_result(min_fee_rate, is_broadcast_suitable), + Err(e) => MaybeFatalTransition::transient(e.into()), + } + } + + /// Extracts the original PSBT so caller can check that the proposal can be broadcasted. + /// + /// Result of the broadcastibility check should then be returned to + /// [`Receiver::process_broadcast_suitability_result`]. + /// + /// 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,...}`. + pub fn extract_tx_to_check_broadcast_suitability(&self) -> bitcoin::Transaction { + self.original.psbt.clone().extract_tx_unchecked_fee_rate() + } + + /// Processes the result of whether the original PSBT in the proposal can be broadcasted. + /// + /// Call [`Receiver::extract_tx_to_check_broadcast_suitability`] first to + /// acquire the tx to be checked for broadcastibility. + /// + /// 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 process_broadcast_suitability_result( + self, + min_fee_rate: Option, + is_broadcast_suitable: bool, + ) -> MaybeFatalTransition< + SessionEvent, + Receiver, + Error, + Receiver, + > { + match self + .state + .original + .process_broadcast_suitability_result(min_fee_rate, is_broadcast_suitable) + { Ok(()) => MaybeFatalTransition::success( SessionEvent::CheckedBroadcastSuitability(), Receiver { @@ -628,7 +690,11 @@ pub struct MaybeInputsOwned { /// typestate. The receiver can call [`Receiver::extract_tx_to_schedule_broadcast`] /// to extract the signed original PSBT to schedule a fallback in case the Payjoin process fails. /// -/// Call [`Receiver::check_inputs_not_owned`] to proceed. +/// There are two options for proceeding to the next typestate. The synchronous, +/// blocking option is to call [`Receiver::check_inputs_not_owned`]. The asynchronous, +/// non-blocking option is call [`Receiver::get_inputs_owned_validator`] to obtain +/// an inputs owned [`InputsOwnedValidator`], run the validator to obtain a [`FinalizedValidator`], +/// and return the [`FinalizedValidator`] to [`Receiver::process_inputs_owned_validator`]. impl Receiver { /// Extracts the original transaction received from the sender. /// @@ -639,7 +705,11 @@ impl Receiver { self.original.psbt.clone().extract_tx_unchecked_fee_rate() } - /// Check that the original PSBT has no receiver-owned inputs. + /// Check that the original PSBT has no receiver owned inputs. + /// + /// The is_owned callback taken here must be a synchronous, blocking one. For + /// asynchronous, non-blocking option use [`Receiver::get_inputs_owned_validator`] + /// in conjunction with [`Receiver::process_inputs_owned_validator`]. /// /// An attacker can try to spend the receiver's own inputs. This check prevents that. pub fn check_inputs_not_owned( @@ -651,7 +721,63 @@ impl Receiver { Error, Receiver, > { - match self.state.original.check_inputs_not_owned(is_owned) { + let validator = self.get_inputs_owned_validator(); + let finalized_validator = match validator { + Ok(validator) => validator.run(&mut |script_buf| is_owned(script_buf.as_script())), + Err(e) => match e { + Error::Implementation(_) => return MaybeFatalTransition::transient(e), + _ => + return MaybeFatalTransition::replyable_error( + SessionEvent::GotReplyableError((&e).into()), + Receiver { + state: HasReplyableError { error_reply: (&e).into() }, + session_context: self.session_context, + }, + e, + ), + }, + }; + match finalized_validator { + Ok(finalized_validator) => self.process_inputs_owned_validator(finalized_validator), + Err(e) => MaybeFatalTransition::transient(e.into()), + } + } + + /// Get an inputs owned [`InputsOwnedValidator`] that will check that the original PSBT + /// has no receiver owned inputs. + /// + /// Once the [`InputsOwnedValidator`] has been run it will produce a [`FinalizedValidator`] + /// which should be returned to [`Receiver::process_inputs_owned_validator`]. + /// + /// An attacker can try to spend the receiver's own inputs. This check prevents that. + pub fn get_inputs_owned_validator( + &self, + ) -> Result>>, Error> + { + self.state.original.get_inputs_owned_validator() + } + + /// Process inputs owned [`FinalizedValidator`] to confirm that the original + /// PSBT has no receiver owned inputs. + /// + /// This takes a [`FinalizedValidator`] which should be produced by running + /// the [`InputsOwnedValidator`] returned from [`Receiver::get_inputs_owned_validator`]. + /// + /// An attacker can try to spend the receiver's own inputs. This check prevents that. + pub fn process_inputs_owned_validator( + self, + finalized_validator: FinalizedValidator< + impl IntoIterator>, + ScriptBuf, + InputOwnedTag, + >, + ) -> MaybeFatalTransition< + SessionEvent, + Receiver, + Error, + Receiver, + > { + match self.state.original.process_inputs_owned_validator(finalized_validator) { Ok(inner) => inner, Err(e) => match e { Error::Implementation(_) => { @@ -692,12 +818,21 @@ pub struct MaybeInputsSeen { original: OriginalPayload, } -/// Typestate to check that the original PSBT has no inputs that the receiver has seen before. +/// Typestate to check that the original PSBT has no inputs that the receiver has +/// seen before. /// -/// Call [`Receiver::check_no_inputs_seen_before`] to proceed. +/// There are two options for proceeding to the next typestate. The synchronous, +/// blocking option is to call [`Receiver::check_no_inputs_seen_before`]. The asynchronous, +/// non-blocking option is call [`Receiver::get_inputs_seen_validator`] to obtain +/// an inputs owned [`InputsSeenValidator`], run the validator to obtain a [`FinalizedValidator`], +/// and return the [`FinalizedValidator`] to [`Receiver::process_inputs_seen_validator`]. impl Receiver { /// Check that the receiver has never seen the inputs in the original proposal before. /// + /// The is_known callback taken here must be a synchronous, blocking one. For + /// asynchronous, non-blocking option use [`Receiver::get_inputs_seen_validator`] + /// in conjunction with [`Receiver::process_inputs_seen_validator`]. + /// /// 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 @@ -713,7 +848,58 @@ impl Receiver { Error, Receiver, > { - match self.state.original.check_no_inputs_seen_before(is_known) { + let validator = self.get_inputs_seen_validator(); + let finalized_validator = validator.run(is_known); + match finalized_validator { + Ok(finalized_validator) => self.process_inputs_seen_validator(finalized_validator), + Err(e) => MaybeFatalTransition::transient(e.into()), + } + } + + /// Get an inputs owned [`InputsSeenValidator`] that will check that the original PSBT + /// does not contain inputs that the receiver has seen before. + /// + /// Once the [`InputsSeenValidator`] has been run it will produce a [`FinalizedValidator`] + /// which should be returned to [`Receiver::process_inputs_seen_validator`]. + /// + /// 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 get_inputs_seen_validator( + &self, + ) -> InputsSeenValidator>> { + self.state.original.get_inputs_seen_validator() + } + + /// Process inputs seen [`FinalizedValidator`] to confirm that the original + /// PSBT does not contain inputs that the receiver has seen before. + /// + /// This takes a [`FinalizedValidator`] which should be produced by running + /// the [`InputsSeenValidator`] returned from [`Receiver::get_inputs_seen_validator`]. + /// + /// 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 process_inputs_seen_validator( + self, + finalized_validator: FinalizedValidator< + impl IntoIterator>, + OutPoint, + InputSeenTag, + >, + ) -> MaybeFatalTransition< + SessionEvent, + Receiver, + Error, + Receiver, + > { + match self.state.original.process_inputs_seen_validator(finalized_validator) { Ok(inner) => inner, Err(e) => match e { Error::Implementation(_) => { @@ -756,14 +942,22 @@ pub struct OutputsUnknown { /// 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 which actually send them +/// The receiver should only accept the original PSBTs from the sender if it actually sends them /// money. /// -/// Call [`Receiver::identify_receiver_outputs`] to proceed. +/// There are two options for proceeding to the next typestate. The synchronous, +/// blocking option is to call [`Receiver::identify_receiver_outputs`]. The asynchronous, +/// non-blocking option is call [`Receiver::get_outputs_owned_validator`] to obtain +/// an outputs owned [`OutputsOwnedValidator`], run the validator to obtain a [`FinalizedValidator`], +/// and return the [`FinalizedValidator`] to [`Receiver::process_outputs_owned_validator`]. impl Receiver { /// Validates whether the original PSBT contains outputs which pay to the receiver and only /// then proceeds to the next typestate. /// + /// The is_receiver_output callback taken here must be a synchronous, blocking one. For + /// asynchronous, non-blocking option use [`Receiver::get_outputs_owned_validator`] + /// in conjunction with [`Receiver::process_outputs_owned_validator`]. + /// /// 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 @@ -780,7 +974,61 @@ impl Receiver { Error, Receiver, > { - let inner = match self.state.original.identify_receiver_outputs(is_receiver_output) { + let validator = self.get_outputs_owned_validator(); + let finalized_validator = + validator.run(&mut |script_buf| is_receiver_output(script_buf.as_script())); + match finalized_validator { + Ok(finalized_validator) => self.process_outputs_owned_validator(finalized_validator), + Err(e) => MaybeFatalTransition::transient(e.into()), + } + } + + /// Get an outputs owned [`OutputsOwnedValidator`] that will check that the original PSBT + /// contains outputs which pay the receiver and only then proceeds to the next typestate. + /// + /// Once the [`OutputsOwnedValidator`] has been run it will produce a [`FinalizedValidator`] + /// which should be returned to [`Receiver::process_outputs_owned_validator`]. + /// + /// 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. + pub fn get_outputs_owned_validator( + &self, + ) -> OutputsOwnedValidator>> { + self.state.original.get_outputs_owned_validator() + } + + /// Process outputs owned [`FinalizedValidator`] to confirm that the original PSBT + /// contains outputs which pay the receiver and only then proceeds to the next typestate. + /// + /// This takes a [`FinalizedValidator`] which should be produced by running + /// the [`OutputsOwnedValidator`] returned from [`Receiver::get_outputs_owned_validator`]. + /// + /// 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. + pub fn process_outputs_owned_validator( + self, + finalized_validator: FinalizedValidator< + impl IntoIterator>, + ScriptBuf, + OutputOwnedTag, + >, + ) -> MaybeFatalTransition< + SessionEvent, + Receiver, + Error, + Receiver, + > { + let inner = match self.state.original.process_outputs_owned_validator(finalized_validator) { Ok(inner) => inner, Err(e) => match e { Error::Implementation(_) => { @@ -1022,7 +1270,11 @@ pub struct ProvisionalProposal { /// by the receiver. The receiver may sign and finalize the Payjoin proposal which will be sent to /// the sender for their signature. /// -/// Call [`Receiver::finalize_proposal`] to return a finalized [`PayjoinProposal`]. +/// There are two options for proceeding to the finalized [`PayjoinProposal`] +/// typestate. The synchronous, blocking option is to call [`Receiver::finalize_proposal`]. +/// The asynchronous, non-blocking option is call [`Receiver::psbt_to_sign`] to +/// extract the psbt to be signed and [`Receiver::finalize_signed_proposal`] to return the +/// receiver signed proposal PSBT. impl Receiver { /// Finalizes the Payjoin proposal into a PSBT which the sender will find acceptable before /// they re-sign the transaction and broadcast it to the network. @@ -1034,9 +1286,37 @@ impl Receiver { self, wallet_process_psbt: impl Fn(&Psbt) -> Result, ) -> MaybeTransientTransition, ImplementationError> + { + let psbt = self.state.psbt_context.payjoin_psbt_without_sender_signatures(); + let signed_psbt = wallet_process_psbt(&psbt); + match signed_psbt { + Ok(signed_psbt) => self.finalize_signed_proposal(&signed_psbt), + Err(e) => MaybeTransientTransition::transient(e), + } + } + + /// Returns the Payjoin proposal PSBT to be signed by receiver before being finalized + /// and sent to sender. + /// + /// After this payjoin proposal PSBT is signed by receiver it should then be returned to + /// [`Receiver::finalize_signed_proposal`] to be finalized. + pub fn psbt_to_sign(&self) -> Psbt { + self.psbt_context.payjoin_psbt_without_sender_signatures() + } + + /// Finalizes the Payjoin proposal into a PSBT which the sender will find acceptable before + /// they sign the transaction and broadcast it to the network. + /// + /// This takes a receiver signed PSBT payjoin proposal and finalizes it for broadcast to + /// the sender. Use [`Receiver::psbt_to_sign`] to obtain the payjoin + /// proposal's unsigned PSBT for receiver to sign and return here. + pub fn finalize_signed_proposal( + self, + signed_psbt: &Psbt, + ) -> MaybeTransientTransition, ImplementationError> { let original_psbt = self.state.psbt_context.original_psbt.clone(); - let inner = match self.state.psbt_context.finalize_proposal(wallet_process_psbt) { + let inner = match self.state.psbt_context.finalize_proposal(signed_psbt) { Ok(inner) => inner, Err(e) => { return MaybeTransientTransition::transient(e); @@ -1050,13 +1330,6 @@ impl Receiver { ) } - /// The Payjoin proposal PSBT that the receiver needs to sign - /// - /// In some applications the entity that progresses the typestate - /// is different from the entity that has access to the private keys, - /// so the PSBT to sign must be accessible to such implementers. - pub fn psbt_to_sign(&self) -> Psbt { self.state.psbt_context.payjoin_psbt.clone() } - pub(crate) fn apply_payjoin_proposal(self, payjoin_psbt: Psbt) -> ReceiveSession { let psbt_context = PsbtContext { payjoin_psbt, diff --git a/payjoin/tests/integration.rs b/payjoin/tests/integration.rs index 0936319c9..32eedf59d 100644 --- a/payjoin/tests/integration.rs +++ b/payjoin/tests/integration.rs @@ -194,7 +194,7 @@ mod integration { use std::sync::Arc; use std::time::Duration; - use bitcoin::{Address, Transaction}; + use bitcoin::{Address, ScriptBuf, Transaction}; use http::StatusCode; use payjoin::persist::{NoopSessionPersister, OptionalTransitionOutcome}; use payjoin::receive::v2::{ @@ -775,6 +775,80 @@ mod integration { Ok(()) } + #[tokio::test] + async fn v2_to_v2_non_blocking_receiver_transitions() -> Result<(), BoxSendSyncError> { + init_tracing(); + let mut services = TestServices::initialize().await?; + let expected_weight = Weight::from_wu( + TX_HEADER_WEIGHT + (P2WPKH_INPUT_WEIGHT * 2) + P2WPKH_OUTPUT_WEIGHT, + ); + let expected_fee = expected_weight * FeeRate::BROADCAST_MIN; + + let (_bitcoind, sender, receiver) = + init_bitcoind_sender_receiver(Some(AddressType::Bech32), Some(AddressType::Bech32)) + .expect("should be able to initialize the sender and the receiver"); + let recv_persister = InMemoryTestPersister::default(); + let send_persister = InMemoryTestPersister::default(); + + let result = tokio::select!( + err = services.take_ohttp_relay_handle() => panic!("Ohttp relay exited early: {:?}", err), + err = services.take_directory_handle() => panic!("Directory server exited early: {:?}", err), + res = do_v2_to_v2_non_blocking_receiver_transitions(&services, &receiver, &sender, &recv_persister, &send_persister, SenderFinalAction::SignAndBroadcastPayjoinProposal) => res + ); + + assert!(result.is_ok(), "v2 p2wpkh send receive failed: {:#?}", result.unwrap_err()); + + let (broadcasted_transaction, monitoring_payment) = result.unwrap(); + + // Sender should have sent the entire value of their UTXO to receiver (minus fees). + assert_eq!(broadcasted_transaction.input.len(), 2); + assert_eq!(broadcasted_transaction.output.len(), 1); + assert_eq!( + receiver.get_balances()?.into_model()?.mine.untrusted_pending, + Amount::from_btc(100.0)? - expected_fee + ); + assert_eq!( + sender.get_balances()?.into_model()?.mine.untrusted_pending, + Amount::from_btc(0.0)? + ); + + // Receiver should be able to validate that the sender has broadcasted the Payjoin proposal. + monitoring_payment + .check_payment(|txid| { + let get_tx_result = receiver.get_raw_transaction(txid); + match get_tx_result { + Ok(tx) => + Ok(Some(tx.transaction().expect("transaction should be decodable"))), + Err(_) => { + panic!("should be able to find the payjoin proposal broadcasted") + } + } + }) + .save(&recv_persister) + .expect("receiver should successfully monitor for the payment"); + + // Receiver session should have completed with a Success, along with information on the + // sender signatures on the Payjoin that was broadcasted. + let (_session, session_history) = replay_receiver_event_log(&recv_persister)?; + let sender_outpoint = session_history.fallback_tx().unwrap().input[0].previous_output; + let sender_signatures = { + let sender_txin = broadcasted_transaction + .input + .iter() + .find(|txin| txin.previous_output == sender_outpoint) + .expect("sender input must be present in payjoin_tx") + .clone(); + vec![(sender_txin.clone().script_sig, sender_txin.clone().witness)] + }; + assert_eq!( + recv_persister.load().unwrap().last(), + Some(payjoin::receive::v2::SessionEvent::Closed(payjoin::receive::v2::SessionOutcome::Success(sender_signatures))), + "The last event of the persister should be a SessionOutcome::Success with the correct sender signature", + ); + assert_eq!(session_history.status(), SessionStatus::Completed); + Ok(()) + } + /// Helper function for running a Payjoin v2 session. Uses the `sender_final_action` /// parameter to determine what action the sender will take after they receive the Payjoin /// proposal from the receiver. @@ -914,6 +988,146 @@ mod integration { Ok((broadcasted_transaction, monitoring_payment)) } + /// Helper function for running a Payjoin v2 session with receiver non-blocking + /// validation flow. Uses the `sender_final_action` parameter to determine what + /// action the sender will take after they receive the Payjoin proposal from the receiver. + /// + /// Returns the transaction which the sender broadcasts and the state of the Receiver + /// before they begin monitoring ([`Receiver`]) so that different tests can modify + /// how the receiver is going to validate the action the sender takes. + async fn do_v2_to_v2_non_blocking_receiver_transitions( + services: &TestServices, + receiver: &corepc_node::Client, + sender: &corepc_node::Client, + recv_persister: &R, + send_persister: &S, + sender_final_action: SenderFinalAction, + ) -> Result<(Transaction, Receiver), BoxError> + where + R: SessionPersister + Clone, + S: SessionPersister + Clone, + { + let agent = services.http_agent(); + services.wait_for_services_ready().await?; + let ohttp_keys = services.fetch_ohttp_keys().await?; + // ********************** + // Inside the Receiver: + let address = receiver.new_address()?; + + // test session with expiration in the future + let session = + ReceiverBuilder::new(address, services.directory_url().as_str(), ohttp_keys)? + .build() + .save(recv_persister)?; + println!("session: {:#?}", &session); + // Poll receive request + let (req, ctx) = session.create_poll_request(services.ohttp_relay_url().as_str())?; + let response = agent + .post(req.url) + .header("Content-Type", req.content_type) + .body(req.body) + .send() + .await?; + assert!(response.status().is_success(), "error response: {}", response.status()); + let response_body = session + .process_response(response.bytes().await?.to_vec().as_slice(), ctx) + .save(recv_persister)?; + // No proposal yet since sender has not responded + let session = if let OptionalTransitionOutcome::Stasis(current_state) = response_body { + current_state + } else { + panic!("Should still be in initialized state") + }; + + // ********************** + // Inside the Sender: + // Create a funded PSBT (not broadcasted) to address with amount given in the pj_uri + let pj_uri = Uri::from_str(&session.pj_uri().to_string()) + .map_err(|e| e.to_string())? + .assume_checked() + .check_pj_supported() + .map_err(|e| e.to_string())?; + let psbt = build_sweep_psbt(sender, &pj_uri)?; + let req_ctx = SenderBuilder::new(psbt, pj_uri) + .build_recommended(FeeRate::BROADCAST_MIN)? + .save(send_persister)?; + let (Request { url, body, content_type, .. }, send_ctx) = + req_ctx.create_v2_post_request(services.ohttp_relay_url().as_str())?; + let response = + agent.post(url).header("Content-Type", content_type).body(body).send().await?; + tracing::info!("Response: {:#?}", &response); + assert!(response.status().is_success(), "error response: {}", response.status()); + let send_ctx = req_ctx + .process_response(&response.bytes().await?, send_ctx) + .save(send_persister)?; + // POST Original PSBT + + // ********************** + // Inside the Receiver: + + // GET fallback psbt + let (req, ctx) = session.create_poll_request(services.ohttp_relay_url().as_str())?; + let response = agent + .post(req.url) + .header("Content-Type", req.content_type) + .body(req.body) + .send() + .await?; + // POST payjoin + let outcome = session + .process_response(response.bytes().await?.to_vec().as_slice(), ctx) + .save(recv_persister)?; + let proposal = if let OptionalTransitionOutcome::Progress(psbt) = outcome { + psbt + } else { + panic!("proposal should exist"); + }; + let payjoin_proposal = + handle_directory_proposal_non_blocking(receiver, proposal, recv_persister, None) + .await?; + let (req, ctx) = + payjoin_proposal.create_post_request(services.ohttp_relay_url().as_str())?; + let response = agent + .post(req.url) + .header("Content-Type", req.content_type) + .body(req.body) + .send() + .await?; + let monitoring_payment = payjoin_proposal + .process_response(&response.bytes().await?, ctx) + .save(recv_persister)?; + + // ********************** + // Inside the Sender: + // Sender checks, signs, finalizes, constructs, and broadcasts + // Replay post fallback to get the response + let (Request { url, body, content_type, .. }, ohttp_ctx) = + send_ctx.create_poll_request(services.ohttp_relay_url().as_str())?; + let response = + agent.post(url).header("Content-Type", content_type).body(body).send().await?; + tracing::info!("Response: {:#?}", &response); + let response = send_ctx + .process_response(&response.bytes().await?, ohttp_ctx) + .save(send_persister) + .expect("psbt should exist"); + + let checked_payjoin_proposal_psbt = + if let OptionalTransitionOutcome::Progress(psbt) = response { + psbt + } else { + panic!("psbt should exist"); + }; + + let broadcasted_transaction = match sender_final_action { + SenderFinalAction::SignAndBroadcastPayjoinProposal => + extract_pj_tx(sender, checked_payjoin_proposal_psbt.clone())?, + SenderFinalAction::BroadcastFallbackTransaction => + replay_sender_event_log(send_persister)?.1.fallback_tx(), + }; + sender.send_raw_transaction(&broadcasted_transaction)?; + Ok((broadcasted_transaction, monitoring_payment)) + } + #[test] fn v2_to_v1() -> Result<(), BoxError> { init_tracing(); @@ -1228,6 +1442,125 @@ mod integration { Ok(payjoin) } + async fn handle_directory_proposal_non_blocking( + receiver: &corepc_node::Client, + proposal: Receiver, + recv_persister: &impl SessionPersister, + custom_inputs: Option>, + ) -> Result, BoxError> { + // Receive Check 1: Can Broadcast + let tx = proposal.extract_tx_to_check_broadcast_suitability(); + let is_broadcast_suitable = receiver + .test_mempool_accept(std::slice::from_ref(&tx)) + .map_err(ImplementationError::new)? + .0 + .first() + .ok_or(ImplementationError::from("testmempoolaccept should return a result"))? + .allowed; + let proposal = proposal + .process_broadcast_suitability_result(None, is_broadcast_suitable) + .save(recv_persister)?; + + // in a payment processor where the sender could go offline, this is where you schedule to broadcast the original_tx + let _to_broadcast_in_failure_case = proposal.extract_tx_to_schedule_broadcast(); + + // Receive Check 2: receiver can't sign for proposal inputs + let inputs_owned_validator = proposal.get_inputs_owned_validator()?; + let mut input_owned_check = |input_script_buf: &ScriptBuf| { + let input_script_buf = input_script_buf.clone(); + async move { + let input = input_script_buf.as_script(); + let address = bitcoin::Address::from_script(input, bitcoin::Network::Regtest) + .map_err(ImplementationError::new)?; + receiver + .get_address_info(&address) + .map(|info| info.is_mine) + .map_err(ImplementationError::new) + } + }; + let finalized_inputs_owned_validator = + inputs_owned_validator.run_async(&mut input_owned_check).await?; + let proposal = proposal + .process_inputs_owned_validator(finalized_inputs_owned_validator) + .save(recv_persister)?; + + // Receive Check 3: have we seen this input before? More of a check for non-interactive i.e. payment processor receivers. + let inputs_seen_validator = proposal.get_inputs_seen_validator(); + let mut input_seen_check = |_outpoint: &OutPoint| async move { Ok(false) }; + let finalized_inputs_seen_validator = + inputs_seen_validator.run_async(&mut input_seen_check).await?; + let proposal = proposal + .process_inputs_seen_validator(finalized_inputs_seen_validator) + .save(recv_persister)?; + let outputs_owned_validator = proposal.get_outputs_owned_validator(); + let mut output_owned_check = |output_script_buf: &ScriptBuf| { + let output_script_buf = output_script_buf.clone(); + async move { + let output_script = output_script_buf.as_script(); + let address = + bitcoin::Address::from_script(output_script, bitcoin::Network::Regtest) + .map_err(ImplementationError::new)?; + receiver + .get_address_info(&address) + .map(|info| info.is_mine) + .map_err(ImplementationError::new) + } + }; + let finalized_outputs_owned_validator = + outputs_owned_validator.run_async(&mut output_owned_check).await?; + let payjoin = proposal + .process_outputs_owned_validator(finalized_outputs_owned_validator) + .save(recv_persister)?; + + let payjoin = payjoin.commit_outputs().save(recv_persister)?; + + let inputs = match custom_inputs { + Some(inputs) => inputs, + None => { + let candidate_inputs = receiver + .list_unspent() + .map_err(ImplementationError::new)? + .0 + .into_iter() + .map(input_pair_from_list_unspent); + let selected_input = + payjoin.try_preserving_privacy(candidate_inputs).map_err(|e| { + format!("Failed to make privacy preserving selection: {e:?}") + })?; + vec![selected_input] + } + }; + let payjoin = payjoin + .contribute_inputs(inputs) + .map_err(|e| format!("Failed to contribute inputs: {e:?}"))? + .commit_inputs() + .save(recv_persister)?; + + let payjoin = payjoin + .apply_fee_range( + Some(FeeRate::BROADCAST_MIN), + Some(FeeRate::from_sat_per_vb_unchecked(2)), + ) + .save(recv_persister)?; + + // Sign and finalize the proposal PSBT + let psbt = payjoin.psbt_to_sign(); + let signed_psbt = receiver + // call RPC manually to pass custom options + .call::( + "walletprocesspsbt", + &[ + json!(psbt.to_string()), + json!(None as Option), + json!(None as Option<&str>), + json!(Some(true)), // check that the receiver properly clears keypaths + ], + ) + .map(|res| Psbt::from_str(&res.psbt).expect("psbt should be valid"))?; + let payjoin = payjoin.finalize_signed_proposal(&signed_psbt).save(recv_persister)?; + Ok(payjoin) + } + pub fn build_sweep_psbt( sender: &corepc_node::Client, pj_uri: &PjUri,