From 8bcc08bb9cc2890a0ec434910a58799909fe4736 Mon Sep 17 00:00:00 2001 From: xstoicunicornx Date: Wed, 13 May 2026 00:28:42 -0500 Subject: [PATCH 1/3] Add non-blocking receive interface Introduce an implementation-agnostic interface for receiver typestates that currently require callback-based validation to advance. Previously, each validation step demanded a synchronous closure, coupling the state machine to the caller's execution model. This made integration difficult for wallets where signing, broadcast checks, or ownership lookups are asynchronous or handled by a separate process. Each callback-based transition is now split into a two-phase pattern: a method to extract the data that needs checking (get_*_checklist, extract_tx_*, psbt_to_sign) and a corresponding method to submit results and advance the state (apply_*_checklist, apply_broadcast_suitability, finalize_signed_proposal). A lightweight ChecklistItem/MarkedChecklistItem framework ensures completeness and ordering of the submitted checks at runtime. This applies across v1 and v2 receiver flows, including input ownership, input-seen, output ownership, broadcast suitability, proposal finalization, and transaction monitoring. The original closure-based methods are preserved as convenience wrappers over the new API, so this is backward-compatible for existing integrators. Co-authored-by: spacebear21 --- payjoin/src/core/receive/error.rs | 5 + payjoin/src/core/receive/mod.rs | 358 ++++++++++++++++++++++++----- payjoin/src/core/receive/v1/mod.rs | 149 +++++++++++- payjoin/src/core/receive/v2/mod.rs | 345 +++++++++++++++++++++++---- 4 files changed, 740 insertions(+), 117 deletions(-) diff --git a/payjoin/src/core/receive/error.rs b/payjoin/src/core/receive/error.rs index d49066ae8..5ca346392 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 e8048b1f5..827d25cf9 100644 --- a/payjoin/src/core/receive/mod.rs +++ b/payjoin/src/core/receive/mod.rs @@ -10,6 +10,7 @@ //! version 1, refer to the `receive::v1` module documentation after enabling the `v1` feature. use std::collections::BTreeMap; +use std::marker::PhantomData; use std::str::FromStr; use bitcoin::transaction::InputWeightPrediction; @@ -231,6 +232,124 @@ impl<'a> From<&'a InputPair> for InternalInputPair<'a> { fn from(pair: &'a InputPair) -> Self { Self { psbtin: &pair.psbtin, txin: &pair.txin } } } +mod sealed { + pub trait ChecklistKind {} + impl ChecklistKind for super::InputOwnership {} + impl ChecklistKind for super::InputSeenBefore {} + impl ChecklistKind for super::OutputOwnership {} +} + +/// Trait that associates a checklist kind with its value type. +/// +/// This trait is sealed and cannot be implemented outside of this crate. +pub trait ChecklistKind: sealed::ChecklistKind { + type Value: Clone + std::fmt::Debug; +} + +/// Checklist kind for checking that the original PSBT inputs are not owned by the receiver. +#[derive(Debug)] +pub struct InputOwnership; + +impl ChecklistKind for InputOwnership { + type Value = OutPoint; +} + +/// Checklist kind for checking that the original PSBT inputs have not been seen before. +#[derive(Debug)] +pub struct InputSeenBefore; + +impl ChecklistKind for InputSeenBefore { + type Value = OutPoint; +} + +/// Checklist kind for checking that the original PSBT outputs are owned by the receiver. +#[derive(Debug)] +pub struct OutputOwnership; + +impl ChecklistKind for OutputOwnership { + type Value = ScriptBuf; +} + +/// Holds a checklist value that requires some form of boolean check. +#[derive(Debug)] +pub struct ChecklistItem { + value: K::Value, + index: usize, + final_index: usize, + _kind: PhantomData, +} + +impl ChecklistItem { + fn new(value: K::Value, index: usize, final_index: usize) -> Self { + ChecklistItem { value, index, final_index, _kind: PhantomData } + } + + /// Returns a [`MarkedChecklistItem`] that has been marked with the result of the boolean + /// check. + pub fn mark(self, result: bool) -> MarkedChecklistItem { + MarkedChecklistItem { item: self, result } + } + pub fn value(&self) -> &K::Value { &self.value } + pub fn index(&self) -> usize { self.index } +} + +/// Holds the result of a [`ChecklistItem`]. Can only be constructed with [`ChecklistItem::mark`]. +#[derive(Debug)] +pub struct MarkedChecklistItem { + item: ChecklistItem, + result: bool, +} + +impl MarkedChecklistItem { + pub fn result(&self) -> bool { self.result } + pub fn value(&self) -> &K::Value { self.item.value() } + pub fn index(&self) -> usize { self.item.index() } + fn final_index(&self) -> usize { self.item.final_index } +} + +/// Helper function to run validation callback over a list of [`ChecklistItem`]s +pub fn mark_checklist( + checklist: impl IntoIterator>, + check: &mut impl FnMut(&K::Value) -> Result, +) -> Result>, ImplementationError> { + let mut marked_checklist: Vec> = vec![]; + for item in checklist { + let result = check(item.value())?; + marked_checklist.push(item.mark(result)); + } + Ok(marked_checklist.into_iter()) +} + +/// Validate that the [`MarkedChecklistItem`]s are in the correct order and are a complete set. +fn validate_checklist( + marked_checklist: impl IntoIterator>, +) -> Result>, ImplementationError> { + let items: Vec> = marked_checklist.into_iter().collect(); + let final_index = items + .first() + .ok_or_else(|| ImplementationError::from("Validation error: empty checklist"))? + .final_index(); + + if items.len() != final_index + 1 { + return Err(ImplementationError::from( + "Validation error: checklist length does not match expected length", + )); + } + for (current_index, item) in items.iter().enumerate() { + if item.index() != current_index { + let msg = + format!("Validation error: unexpected checklist item at index {current_index}"); + return Err(ImplementationError::from(msg.as_str())); + } + if item.final_index() != final_index { + return Err(ImplementationError::from( + "Validation error: checklist has inconsistent expected length", + )); + } + } + Ok(items.into_iter()) +} + /// Validate the payload of a Payjoin request for PSBT and Params sanity pub(crate) fn parse_payload( base64: &str, @@ -267,7 +386,7 @@ fn psbt_input_is_signed(input: &bitcoin::psbt::Input) -> bool { 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 @@ -340,16 +459,12 @@ impl PsbtContext { psbt } - /// Finalizes the Payjoin proposal into a PSBT which the sender will find acceptable before + /// Finalizes the signed payjoin proposal PSBT which the sender will find acceptable before /// they sign the transaction and broadcast it to the network. /// - /// Finalization consists of signing and finalizing the PSBT using the passed `wallet_process_psbt` signing function. - fn finalize_proposal( - self, - wallet_process_psbt: impl Fn(&Psbt) -> Result, - ) -> Result { - let psbt = self.psbt_to_sign(); - let signed_psbt = wallet_process_psbt(&psbt)?; + /// Returns a final payjoin proposal PSBT after verifying the signed PSBT matches the payjoin + /// proposal PSBT and sanitizing it. + fn finalize_signed_proposal(&self, signed_psbt: Psbt) -> Result { let expected_ntxid = self.payjoin_psbt.unsigned_tx.compute_ntxid(); let actual_ntxid = signed_psbt.unsigned_tx.compute_ntxid(); if expected_ntxid != actual_ntxid { @@ -393,6 +508,17 @@ impl OriginalPayload { &self, min_fee_rate: Option, can_broadcast: impl Fn(&bitcoin::Transaction) -> Result, + ) -> Result<(), Error> { + self.apply_broadcast_suitability( + min_fee_rate, + can_broadcast(&self.psbt.clone().extract_tx_unchecked_fee_rate())?, + ) + } + + pub fn apply_broadcast_suitability( + &self, + min_fee_rate: Option, + is_broadcast_suitable: bool, ) -> Result<(), Error> { let original_psbt_fee_rate = self.psbt_fee_rate()?; if let Some(min_fee_rate) = min_fee_rate { @@ -404,9 +530,7 @@ impl OriginalPayload { .into()); } } - if can_broadcast(&self.psbt.clone().extract_tx_unchecked_fee_rate()) - .map_err(Error::Implementation)? - { + if is_broadcast_suitable { Ok(()) } else { Err(InternalPayloadError::OriginalPsbtNotBroadcastable.into()) @@ -426,55 +550,117 @@ impl OriginalPayload { &self, is_owned: &mut impl FnMut(&OutPoint) -> Result, ) -> Result<(), Error> { - for input in self.psbt.input_pairs() { - let outpoint = input.txin.previous_output; - if is_owned(&outpoint).map_err(Error::Implementation)? { - return Err(InternalPayloadError::InputOwned(outpoint).into()); - } + let marked_checklist = mark_checklist(self.inputs_owned_checklist(), is_owned)?; + self.apply_inputs_owned_checklist(marked_checklist) + } + + pub fn inputs_owned_checklist(&self) -> impl Iterator> { + let final_index = self.psbt.input_pairs().count() - 1; + let checklist = self + .psbt + .input_pairs() + .enumerate() + .map(|(index, input)| { + ChecklistItem::::new(input.txin.previous_output, index, final_index) + }) + .collect::>(); + checklist.into_iter() + } + + pub fn apply_inputs_owned_checklist( + &self, + marked_checklist: impl IntoIterator>, + ) -> Result<(), Error> { + let validated_checklist = validate_checklist(marked_checklist)?; + match validated_checklist.into_iter().find(|item| item.result()) { + Some(item) => Err(InternalPayloadError::InputOwned(*item.value()).into()), + None => Ok(()), } - Ok(()) } 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 marked_checklist = mark_checklist(self.inputs_seen_checklist(), is_known)?; + self.apply_inputs_seen_checklist(marked_checklist) + } + + pub fn inputs_seen_checklist(&self) -> impl Iterator> { + let final_index = self.psbt.input_pairs().count() - 1; + let checklist = self + .psbt + .input_pairs() + .enumerate() + .map(|(index, input)| { + ChecklistItem::::new( + input.txin.previous_output, + index, + final_index, + ) + }) + .collect::>(); + checklist.into_iter() + } + + pub fn apply_inputs_seen_checklist( + &self, + marked_checklist: impl IntoIterator>, + ) -> Result<(), Error> { + let validated_checklist = validate_checklist(marked_checklist)?; + match validated_checklist.into_iter().find(|item| item.result()) { + Some(item) => { + tracing::warn!("Request contains an input we've seen before: {}. Preventing possible probing attack.", item.value()); + Err(InternalPayloadError::InputSeen(*item.value()))? } - })?; - Ok(()) + None => Ok(()), + } } pub fn identify_receiver_outputs( self, is_receiver_output: &mut impl FnMut(&Script) -> Result, ) -> Result { - let owned_vouts: Vec = self + let marked_checklist = + mark_checklist(self.outputs_owned_checklist(), &mut |script: &ScriptBuf| { + is_receiver_output(script.as_script()) + })?; + self.apply_outputs_owned_checklist(marked_checklist) + } + + pub fn outputs_owned_checklist(&self) -> impl Iterator> { + let final_index = self.psbt.unsigned_tx.output.len() - 1; + let checklist = 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)), + .map(|(index, output)| { + ChecklistItem::::new( + output.script_pubkey.clone(), + index, + final_index, + ) }) - .collect::, _>>() - .map_err(Error::Implementation)?; + .collect::>(); + checklist.into_iter() + } + pub fn apply_outputs_owned_checklist( + &self, + marked_checklist: impl IntoIterator>, + ) -> Result { + let validated_checklist = validate_checklist(marked_checklist)?; + let owned_vouts = validated_checklist + .filter(|item| item.result()) + .map(|item| item.index()) + .collect::>(); if owned_vouts.is_empty() { return Err(InternalPayloadError::MissingPayment.into()); } - Ok(common::WantsOutputs::new(self, owned_vouts)) + Ok(common::WantsOutputs::new(self.clone(), owned_vouts)) } } @@ -523,6 +709,73 @@ pub(crate) mod tests { } } + #[test] + fn checklist_item_mark_preserves_value_and_index() { + let outpoint = OutPoint::null(); + let item = ChecklistItem::::new(outpoint, 2, 4); + + // The unmarked item exposes its value. + assert_eq!(item.value(), &outpoint); + + // Marking consumes the item and carries value and index through, alongside the result. + let marked = item.mark(true); + assert_eq!(marked.value(), &outpoint); + assert_eq!(marked.index(), 2); + assert!(marked.result()); + + // A false result is recorded faithfully. + let item = ChecklistItem::::new(outpoint, 0, 0); + let marked = item.mark(false); + assert!(!marked.result()); + assert_eq!(marked.value(), &outpoint); + assert_eq!(marked.index(), 0); + } + + #[test] + fn validate_checklist_covers_all_outcomes() { + fn item(index: usize, final_index: usize) -> MarkedChecklistItem { + ChecklistItem::::new(OutPoint::null(), index, final_index).mark(true) + } + + // A complete, correctly ordered checklist validates and yields every item back in order. + let validated: Vec<_> = validate_checklist(vec![item(0, 2), item(1, 2), item(2, 2)]) + .expect("complete checklist should validate") + .collect(); + assert_eq!(validated.len(), 3); + for (i, marked) in validated.iter().enumerate() { + assert_eq!(marked.index(), i); + } + + // An empty checklist has no first item to derive the expected length from. + let empty: Vec> = vec![]; + let err = validate_checklist(empty).err().expect("empty checklist should fail"); + assert!(err.to_string().contains("empty checklist")); + + // A checklist shorter than final_index + 1 is rejected. + let err = validate_checklist(vec![item(0, 2), item(1, 2)]) + .err() + .expect("short checklist should fail"); + assert!(err.to_string().contains("does not match expected length")); + + // A checklist longer than final_index + 1 is rejected. + let err = validate_checklist(vec![item(0, 2), item(1, 2), item(2, 2), item(3, 2)]) + .err() + .expect("long checklist should fail"); + assert!(err.to_string().contains("does not match expected length")); + + // Items out of order are rejected at the first offending index. + let err = validate_checklist(vec![item(0, 2), item(2, 2), item(1, 2)]) + .err() + .expect("out-of-order checklist should fail"); + assert!(err.to_string().contains("unexpected checklist item at index 1")); + + // Items disagreeing on the expected length are rejected. + let err = validate_checklist(vec![item(0, 2), item(1, 3), item(2, 2)]) + .err() + .expect("inconsistent checklist should fail"); + assert!(err.to_string().contains("inconsistent expected length")); + } + #[test] fn input_pair_with_expected_weight() { let p2wsh_txout = TxOut { @@ -1101,29 +1354,20 @@ pub(crate) mod tests { #[test] fn test_finalize_proposal() { + // Outcome 1: wallet_process_psbt returns a psbt with mismatched ntxid → ImplementationError let psbt_context = psbt_context_from_test_vector(); - - // Outcome 1: wallet_process_psbt returns an implementation error → ImplementationError let err = psbt_context .clone() - .finalize_proposal(|_| Err(ImplementationError::from("wallet signing failed"))) - .expect_err("Should fail when wallet_process_psbt returns an error"); - assert_eq!(err.to_string(), "wallet signing failed"); - - // Outcome 2: wallet_process_psbt returns a psbt with mismatched ntxid → ImplementationError - let psbt_context = psbt_context_from_test_vector(); - let err = psbt_context - .clone() - .finalize_proposal(|_| { + .finalize_signed_proposal( // return a totally different psbt to trigger ntxid mismatch - Ok(PARSED_ORIGINAL_PSBT.clone()) - }) + PARSED_ORIGINAL_PSBT.clone(), + ) .expect_err("Should fail when ntxid mismatches"); assert!(err.to_string().contains("ntxid mismatch")); - // Outcome 3: wallet_process_psbt succeeds → Ok(Psbt) + // Outcome 2: wallet_process_psbt succeeds → Ok(Psbt) let _psbt = psbt_context - .finalize_proposal(|_| Ok(PARSED_PAYJOIN_PROPOSAL.clone())) + .finalize_signed_proposal(PARSED_PAYJOIN_PROPOSAL.clone()) .expect("Should succeed when wallet_process_psbt returns a valid signed psbt"); } @@ -1135,14 +1379,12 @@ pub(crate) mod tests { .first() .expect("test vector has at least one sender input"); + // Sign a sender-side input. + let mut signed = psbt_context.psbt_to_sign(); + signed.inputs[sender_i].final_script_witness = + Some(bitcoin::Witness::from_slice(&[vec![0x01u8]])); let err = psbt_context - .finalize_proposal(|to_sign| { - // Sign a sender-side input. - let mut signed = to_sign.clone(); - signed.inputs[sender_i].final_script_witness = - Some(bitcoin::Witness::from_slice(&[vec![0x01u8]])); - Ok(signed) - }) + .finalize_signed_proposal(signed) .expect_err("finalize must reject a signed sender input"); assert!(err.to_string().contains("unexpected signature"), "unexpected error: {err}"); } diff --git a/payjoin/src/core/receive/v1/mod.rs b/payjoin/src/core/receive/v1/mod.rs index 22c44c85f..ad66e0baa 100644 --- a/payjoin/src/core/receive/v1/mod.rs +++ b/payjoin/src/core/receive/v1/mod.rs @@ -81,6 +81,10 @@ impl UncheckedOriginalPayload { /// Interactive receivers can skip that check and call /// [`Self::assume_interactive_receiver`] instead. Either path advances to /// [`MaybeInputsOwned`]. +/// +/// To perform the broadcast check without a synchronous callback, use +/// [`Self::extract_tx_to_check_broadcast_suitability`] and +/// [`Self::apply_broadcast_suitability`]. #[derive(Debug, Clone)] pub struct UncheckedOriginalPayload { original: OriginalPayload, @@ -96,7 +100,34 @@ 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.apply_broadcast_suitability(min_fee_rate, can_broadcast(&tx)?) + } + + /// Extract the Original PSBT transaction so the caller can check that it is + /// suitable for broadcast, ensuring it can be used as a fallback if the payjoin + /// does not complete. + /// + /// Submit the result of the check to [`Self::apply_broadcast_suitability`]. + /// + /// Returns the extracted [`bitcoin::Transaction`]. + pub fn extract_tx_to_check_broadcast_suitability(&self) -> bitcoin::Transaction { + self.original.psbt.clone().extract_tx_unchecked_fee_rate() + } + + /// Apply the result of the broadcast suitability check to advance the state machine. + /// + /// Use [`Self::extract_tx_to_check_broadcast_suitability`] to obtain the transaction + /// that needs to be checked. Optionally enforce a minimum fee rate on the Original PSBT + /// to further raise the cost of probing attacks. + /// + /// Returns a [`MaybeInputsOwned`] to continue validation. + pub fn apply_broadcast_suitability( + self, + min_fee_rate: Option, + is_broadcast_suitable: bool, + ) -> Result { + self.original.apply_broadcast_suitability(min_fee_rate, is_broadcast_suitable)?; Ok(MaybeInputsOwned { original: self.original }) } @@ -119,6 +150,9 @@ impl UncheckedOriginalPayload { /// /// Call [`Self::check_inputs_not_owned`] to advance to [`MaybeInputsSeen`] to continue /// validation. +/// +/// To perform this check without a synchronous callback, use +/// [`Self::inputs_owned_checklist`] and [`Self::apply_inputs_owned_checklist`]. #[derive(Debug, Clone)] pub struct MaybeInputsOwned { pub(crate) original: OriginalPayload, @@ -141,7 +175,30 @@ impl MaybeInputsOwned { self, is_owned: &mut impl FnMut(&OutPoint) -> Result, ) -> Result { - self.original.check_inputs_not_owned(is_owned)?; + let marked_checklist = mark_checklist(self.inputs_owned_checklist(), is_owned)?; + self.apply_inputs_owned_checklist(marked_checklist) + } + + /// Get the [`ChecklistItem`]s holding the input outpoints that need to be checked for + /// ownership by the receiver, preventing an attacker from spending the receiver's + /// own inputs. + /// + /// Each [`ChecklistItem`] must be marked with its result to obtain a [`MarkedChecklistItem`], + /// which can then be collected and submitted to [`Self::apply_inputs_owned_checklist`]. + pub fn inputs_owned_checklist(&self) -> impl Iterator> { + self.original.inputs_owned_checklist() + } + + /// Apply the input ownership checklist results to advance the state machine. + /// + /// Use [`Self::inputs_owned_checklist`] to obtain the items that need to be checked. + /// + /// Returns a [`MaybeInputsSeen`] to continue validation. + pub fn apply_inputs_owned_checklist( + self, + marked_checklist: impl IntoIterator>, + ) -> Result { + self.original.apply_inputs_owned_checklist(marked_checklist)?; Ok(MaybeInputsSeen { original: self.original }) } } @@ -157,6 +214,9 @@ impl MaybeInputsOwned { /// /// Call [`Self::check_no_inputs_seen_before`] to advance to [`OutputsUnknown`] to /// continue validation. +/// +/// To perform this check without a synchronous callback, use +/// [`Self::inputs_seen_checklist`] and [`Self::apply_inputs_seen_checklist`]. #[derive(Debug, Clone)] pub struct MaybeInputsSeen { original: OriginalPayload, @@ -171,7 +231,30 @@ impl MaybeInputsSeen { self, is_known: &mut impl FnMut(&OutPoint) -> Result, ) -> Result { - self.original.check_no_inputs_seen_before(is_known)?; + let marked_checklist = mark_checklist(self.inputs_seen_checklist(), is_known)?; + self.apply_inputs_seen_checklist(marked_checklist) + } + + /// Get the [`ChecklistItem`]s holding the input outpoints that need to be checked + /// for whether the receiver has seen them before, preventing input probing and + /// replay attacks. + /// + /// Each [`ChecklistItem`] must be marked with its result to obtain a [`MarkedChecklistItem`], + /// which can then be collected and submitted to [`Self::apply_inputs_seen_checklist`]. + pub fn inputs_seen_checklist(&self) -> impl Iterator> { + self.original.inputs_seen_checklist() + } + + /// Apply the inputs-seen checklist results to advance the state machine. + /// + /// Use [`Self::inputs_seen_checklist`] to obtain the items that need to be checked. + /// + /// Returns an [`OutputsUnknown`] to continue validation. + pub fn apply_inputs_seen_checklist( + self, + marked_checklist: impl IntoIterator>, + ) -> Result { + self.original.apply_inputs_seen_checklist(marked_checklist)?; Ok(OutputsUnknown { original: self.original }) } } @@ -181,6 +264,9 @@ impl MaybeInputsSeen { /// The receiver should only accept Original PSBTs from the sender that actually send /// them money. Call [`Self::identify_receiver_outputs`] to advance to [`WantsOutputs`] /// to continue the proposal. +/// +/// To perform this check without a synchronous callback, use +/// [`Self::outputs_owned_checklist`] and [`Self::apply_outputs_owned_checklist`]. #[derive(Debug, Clone)] pub struct OutputsUnknown { original: OriginalPayload, @@ -199,7 +285,37 @@ impl OutputsUnknown { self, is_receiver_output: &mut impl FnMut(&Script) -> Result, ) -> Result { - self.original.identify_receiver_outputs(is_receiver_output) + let marked_checklist = + mark_checklist(self.outputs_owned_checklist(), &mut |script: &ScriptBuf| { + is_receiver_output(script.as_script()) + })?; + self.apply_outputs_owned_checklist(marked_checklist) + } + + /// Get the [`ChecklistItem`]s holding the output scripts that need to be checked + /// for ownership by the receiver, ensuring at least one output pays the receiver. + /// + /// Each [`ChecklistItem`] must be marked with its result to obtain a [`MarkedChecklistItem`], + /// which can then be collected and submitted to [`Self::apply_outputs_owned_checklist`]. + #[cfg_attr(not(feature = "v1"), allow(dead_code))] + pub fn outputs_owned_checklist(&self) -> impl Iterator> { + self.original.outputs_owned_checklist() + } + + /// Apply the output ownership checklist results to advance the state machine. + /// + /// Use [`Self::outputs_owned_checklist`] to obtain the items that need to be checked. + /// + /// If the sender designated a receiver output for fee subtraction, that designation + /// is cleared so the receiver does not accidentally subtract fees from their own output. + /// + /// Returns a [`WantsOutputs`] to continue the proposal. + #[cfg_attr(not(feature = "v1"), allow(dead_code))] + pub fn apply_outputs_owned_checklist( + &self, + marked_checklist: impl IntoIterator>, + ) -> Result { + self.original.apply_outputs_owned_checklist(marked_checklist) } } @@ -268,6 +384,9 @@ impl crate::receive::common::WantsFeeRange { /// inputs of, and is ready to be signed and finalized. /// /// Call [`Self::finalize_proposal`] to advance to [`PayjoinProposal`]. +/// +/// To sign without a synchronous callback, use [`Self::psbt_to_sign`] and +/// [`Self::finalize_signed_proposal`]. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ProvisionalProposal { psbt_context: PsbtContext, @@ -281,21 +400,31 @@ 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) } /// Extract the PSBT that needs to be signed by the receiver's wallet. /// /// 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. + /// such implementers. Submit the signed PSBT to [`Self::finalize_signed_proposal`]. /// /// Returns the Payjoin proposal [`Psbt`] to be signed. pub fn psbt_to_sign(&self) -> Psbt { self.psbt_context.psbt_to_sign() } + + /// Finalize the receiver-signed Payjoin proposal into a PSBT the sender will find + /// acceptable before they sign and broadcast it to the network. + /// + /// Use [`Self::psbt_to_sign`] to obtain the unsigned PSBT for the receiver to sign + /// and return here. + /// + /// Returns the final [`PayjoinProposal`]. + pub fn finalize_signed_proposal(self, signed_psbt: &Psbt) -> Result { + let finalized_psbt = self.psbt_context.finalize_signed_proposal(signed_psbt.clone())?; + Ok(PayjoinProposal { payjoin_psbt: finalized_psbt }) + } } /// Typestate for a signed and finalized Payjoin proposal that is to be sent to the diff --git a/payjoin/src/core/receive/v2/mod.rs b/payjoin/src/core/receive/v2/mod.rs index 2e6b534c8..62fa14fd9 100644 --- a/payjoin/src/core/receive/v2/mod.rs +++ b/payjoin/src/core/receive/v2/mod.rs @@ -29,7 +29,7 @@ use std::str::FromStr; use std::time::Duration; use bitcoin::psbt::Psbt; -use bitcoin::{Address, Amount, FeeRate, OutPoint, Script, TxOut, Txid}; +use bitcoin::{Address, Amount, FeeRate, OutPoint, Script, ScriptBuf, Transaction, TxOut, Txid}; pub use error::{CreateRequestError, SessionError}; pub(crate) use error::{InternalCreateRequestError, InternalSessionError}; use serde::de::Deserializer; @@ -60,7 +60,10 @@ use crate::persist::{ MaybeTerminalSuccessTransition, MaybeTerminalTransition, MaybeTransientTransition, NextStateTransition, TerminalTransition, }; -use crate::receive::{parse_payload, InputPair, OriginalPayload, PsbtContext}; +use crate::receive::{ + mark_checklist, parse_payload, ChecklistItem, InputOwnership, InputPair, InputSeenBefore, + MarkedChecklistItem, OriginalPayload, OutputOwnership, PsbtContext, +}; use crate::time::Time; use crate::uri::ShortId; use crate::{ImplementationError, IntoUrl, IntoUrlError, Request, Version}; @@ -782,6 +785,10 @@ impl Receiver { /// UTXOs. Interactive receivers can skip that check and call /// [`Receiver::assume_interactive_receiver`] instead. /// Either path advances to [`Receiver`]. +/// +/// To perform the broadcast check without a synchronous callback, use +/// [`Receiver::extract_tx_to_check_broadcast_suitability`] +/// and [`Receiver::apply_broadcast_suitability`]. impl Receiver { /// Check that the sender's Original PSBT is suitable for broadcast, ensuring /// it can be used as a fallback if the payjoin does not complete. @@ -799,7 +806,46 @@ impl Receiver { Receiver, Self, > { - 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.apply_broadcast_suitability(min_fee_rate, is_broadcast_suitable), + Err(e) => MaybeFatalTransition::transient(e.into(), self), + } + } + + /// Extract the Original PSBT transaction so the caller can check that it is + /// suitable for broadcast, ensuring it can be used as a fallback if the payjoin + /// does not complete. + /// + /// Submit the result of the check to + /// [`Receiver::apply_broadcast_suitability`]. + /// + /// Returns the extracted [`bitcoin::Transaction`]. + pub fn extract_tx_to_check_broadcast_suitability(&self) -> bitcoin::Transaction { + self.original.psbt.clone().extract_tx_unchecked_fee_rate() + } + + /// Apply the result of the broadcast suitability check to advance the state machine. + /// + /// Use [`Receiver::extract_tx_to_check_broadcast_suitability`] + /// to obtain the transaction that needs to be checked. Optionally enforce a minimum fee + /// rate on the Original PSBT to further raise the cost of probing attacks. + /// + /// Returns a [`MaybeFatalTransition`] that, once successfully persisted, yields a + /// [`Receiver`] to continue validation. + pub fn apply_broadcast_suitability( + self, + min_fee_rate: Option, + is_broadcast_suitable: bool, + ) -> MaybeFatalTransition< + SessionEvent, + Receiver, + Error, + Receiver, + Self, + > { + match self.state.original.apply_broadcast_suitability(min_fee_rate, is_broadcast_suitable) { Ok(()) => MaybeFatalTransition::success( SessionEvent::CheckedBroadcastSuitability(), Receiver { @@ -864,6 +910,10 @@ pub struct MaybeInputsOwned { /// /// Call [`Receiver::check_inputs_not_owned`] to advance to /// [`Receiver`] to continue validation. +/// +/// To perform this check without a synchronous callback, use +/// [`Receiver::inputs_owned_checklist`] and +/// [`Receiver::apply_inputs_owned_checklist`]. impl Receiver { /// Extract the transaction from the Original PSBT for scheduling broadcast as a /// fallback in case the payjoin does not complete. @@ -888,7 +938,41 @@ impl Receiver { Receiver, Self, > { - match self.state.original.check_inputs_not_owned(is_owned) { + match mark_checklist(self.inputs_owned_checklist(), is_owned) { + Ok(marked_checklist) => self.apply_inputs_owned_checklist(marked_checklist), + Err(e) => MaybeFatalTransition::transient(e.into(), self), + } + } + + /// Get the [`ChecklistItem`]s holding the input outpoints that need to be checked for + /// ownership by the receiver, preventing an attacker from spending the receiver's + /// own inputs. + /// + /// Each [`ChecklistItem`] must be marked with its result to obtain a + /// [`MarkedChecklistItem`], which can then be collected and submitted to + /// [`Receiver::apply_inputs_owned_checklist`]. + pub fn inputs_owned_checklist(&self) -> impl Iterator> { + self.state.original.inputs_owned_checklist() + } + + /// Apply the input ownership checklist results to advance the state machine. + /// + /// Use [`Receiver::inputs_owned_checklist`] to obtain the items + /// that need to be checked. + /// + /// Returns a [`MaybeFatalTransition`] that, once successfully persisted, yields a + /// [`Receiver`] to continue validation. + pub fn apply_inputs_owned_checklist( + self, + marked_checklist: impl IntoIterator>, + ) -> MaybeFatalTransition< + SessionEvent, + Receiver, + Error, + Receiver, + Self, + > { + match self.state.original.apply_inputs_owned_checklist(marked_checklist) { Ok(()) => MaybeFatalTransition::success( SessionEvent::CheckedInputsNotOwned(), Receiver { @@ -941,6 +1025,10 @@ pub struct MaybeInputsSeen { /// /// Call [`Receiver::check_no_inputs_seen_before`] to advance to /// [`Receiver`] to continue validation. +/// +/// To perform this check without a synchronous callback, use +/// [`Receiver::inputs_seen_checklist`] and +/// [`Receiver::apply_inputs_seen_checklist`]. impl Receiver { /// Check that none of the inputs have been seen before, preventing input /// probing and replay attacks (where inputs have been used in a previous @@ -958,7 +1046,41 @@ impl Receiver { Receiver, Self, > { - match self.state.original.check_no_inputs_seen_before(is_known) { + match mark_checklist(self.inputs_seen_checklist(), is_known) { + Ok(marked_checklist) => self.apply_inputs_seen_checklist(marked_checklist), + Err(e) => MaybeFatalTransition::transient(e.into(), self), + } + } + + /// Get the [`ChecklistItem`]s holding the input outpoints that need to be checked + /// for whether the receiver has seen them before, preventing input probing and + /// replay attacks. + /// + /// Each [`ChecklistItem`] must be marked with its result to obtain a + /// [`MarkedChecklistItem`], which can then be collected and submitted to + /// [`Receiver::apply_inputs_seen_checklist`]. + pub fn inputs_seen_checklist(&self) -> impl Iterator> { + self.state.original.inputs_seen_checklist() + } + + /// Apply the inputs-seen checklist results to advance the state machine. + /// + /// Use [`Receiver::inputs_seen_checklist`] to obtain the items + /// that need to be checked. + /// + /// Returns a [`MaybeFatalTransition`] that, once successfully persisted, yields a + /// [`Receiver`] to continue validation. + pub fn apply_inputs_seen_checklist( + self, + marked_checklist: impl IntoIterator>, + ) -> MaybeFatalTransition< + SessionEvent, + Receiver, + Error, + Receiver, + Self, + > { + match self.state.original.apply_inputs_seen_checklist(marked_checklist) { Ok(()) => MaybeFatalTransition::success( SessionEvent::CheckedNoInputsSeenBefore(), Receiver { @@ -1005,6 +1127,10 @@ pub struct OutputsUnknown { /// The receiver should only accept Original PSBTs from the sender that actually send /// them money. Call [`Receiver::identify_receiver_outputs`] to advance /// to [`Receiver`] to continue the proposal. +/// +/// To perform this check without a synchronous callback, use +/// [`Receiver::outputs_owned_checklist`] and +/// [`Receiver::apply_outputs_owned_checklist`]. impl Receiver { /// Identify which outputs in the original transaction belong to the receiver /// and ensure at least one output pays the receiver. @@ -1023,9 +1149,47 @@ impl Receiver { Error, Receiver, Self, + > { + match mark_checklist(self.outputs_owned_checklist(), &mut |script: &ScriptBuf| { + is_receiver_output(script.as_script()) + }) { + Ok(marked_checklist) => self.apply_outputs_owned_checklist(marked_checklist), + Err(e) => MaybeFatalTransition::transient(e.into(), self), + } + } + + /// Get the [`ChecklistItem`]s holding the output scripts that need to be checked + /// for ownership by the receiver, ensuring at least one output pays the receiver. + /// + /// Each [`ChecklistItem`] must be marked with its result to obtain a + /// [`MarkedChecklistItem`], which can then be collected and submitted to + /// [`Receiver::apply_outputs_owned_checklist`]. + pub fn outputs_owned_checklist(&self) -> impl Iterator> { + self.state.original.outputs_owned_checklist() + } + + /// Apply the output ownership checklist results to advance the state machine. + /// + /// Use [`Receiver::outputs_owned_checklist`] to obtain the items + /// that need to be checked. + /// + /// If the sender designated a receiver output for fee subtraction, that designation + /// is cleared so the receiver does not accidentally subtract fees from their own output. + /// + /// Returns a [`MaybeFatalTransition`] that, once successfully persisted, yields a + /// [`Receiver`] to continue the proposal. + pub fn apply_outputs_owned_checklist( + self, + marked_checklist: impl IntoIterator>, + ) -> MaybeFatalTransition< + SessionEvent, + Receiver, + Error, + Receiver, + Self, > { let fallback_tx = Some(self.state.fallback_tx()); - match self.state.original.clone().identify_receiver_outputs(is_receiver_output) { + match self.state.original.apply_outputs_owned_checklist(marked_checklist) { Ok(inner) => MaybeFatalTransition::success( SessionEvent::IdentifiedReceiverOutputs(inner.owned_vouts.clone()), Receiver { state: WantsOutputs { inner }, session_context: self.session_context }, @@ -1319,6 +1483,10 @@ pub struct ProvisionalProposal { /// /// Call [`Receiver::finalize_proposal`] to advance to /// [`Receiver`]. +/// +/// To sign without a synchronous callback, use +/// [`Receiver::psbt_to_sign`] and +/// [`Receiver::finalize_signed_proposal`]. impl Receiver { /// Finalize the proposal by signing the PSBT via the `wallet_process_psbt` callback. /// @@ -1329,17 +1497,46 @@ impl Receiver { wallet_process_psbt: impl Fn(&Psbt) -> Result, ) -> MaybeTransientTransition, ImplementationError, Self> { + let psbt = self.psbt_to_sign(); + let signed_psbt = wallet_process_psbt(&psbt); + match signed_psbt { + Ok(signed_psbt) => self.finalize_signed_proposal(&signed_psbt), + Err(e) => MaybeTransientTransition::transient(e, self), + } + } + + /// Extract the PSBT that needs to be signed by the receiver's wallet. + /// + /// 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. Submit the signed PSBT to + /// [`Receiver::finalize_signed_proposal`]. + /// + /// Returns the Payjoin proposal [`Psbt`] to be signed. + pub fn psbt_to_sign(&self) -> Psbt { self.state.psbt_context.psbt_to_sign() } + + /// Finalize the receiver-signed Payjoin proposal into a PSBT the sender will find + /// acceptable before they sign and broadcast it to the network. + /// + /// Use [`Receiver::psbt_to_sign`] to obtain the unsigned PSBT for + /// the receiver to sign and return here. + /// + /// Returns a [`MaybeTransientTransition`] that, once successfully persisted, yields the + /// final [`Receiver`]. + pub fn finalize_signed_proposal( + self, + signed_psbt: &Psbt, + ) -> MaybeTransientTransition, ImplementationError, Self> + { + let original_psbt = self.state.psbt_context.original_psbt.clone(); let payjoin_psbt = - match self.state.psbt_context.clone().finalize_proposal(wallet_process_psbt) { + match self.state.psbt_context.finalize_signed_proposal(signed_psbt.clone()) { Ok(payjoin_psbt) => payjoin_psbt, Err(e) => { return MaybeTransientTransition::transient(e, self); } }; - let psbt_context = PsbtContext { - payjoin_psbt: payjoin_psbt.clone(), - original_psbt: self.state.psbt_context.original_psbt, - }; + let psbt_context = PsbtContext { payjoin_psbt: payjoin_psbt.clone(), original_psbt }; let payjoin_proposal = PayjoinProposal { psbt_context: psbt_context.clone() }; MaybeTransientTransition::success( SessionEvent::FinalizedProposal(payjoin_psbt), @@ -1347,15 +1544,6 @@ impl Receiver { ) } - /// Extract the PSBT that needs to be signed by the receiver's wallet. - /// - /// 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. - /// - /// Returns the Payjoin proposal [`Psbt`] to be signed. - pub fn psbt_to_sign(&self) -> Psbt { self.state.psbt_context.psbt_to_sign() } - pub(crate) fn apply_payjoin_proposal(self, payjoin_psbt: Psbt) -> ReceiveSession { let psbt_context = PsbtContext { payjoin_psbt, @@ -1617,6 +1805,14 @@ pub struct Monitor { /// /// Call [`Receiver::check_for_transaction`] to confirm the status of the transaction in the /// network and conclude the Payjoin session. +/// +/// To monitor without a synchronous callback, first check +/// [`Receiver::proposal_txid_is_stable`]: if the proposal txid is not stable, +/// conclude the session with [`Receiver::payjoin_sent`]. Otherwise, use +/// [`Receiver::extract_payjoin_proposal_txid`] and +/// [`Receiver::extract_fallback_txid`] to obtain the txids to look up, then +/// conclude the session with [`Receiver::payjoin_tx_exists`] or +/// [`Receiver::fallback_tx_exists`]. impl Receiver { /// Check the network for the payjoin or fallback transaction via the `find_transaction` /// callback. @@ -1628,39 +1824,18 @@ impl Receiver { self, find_transaction: impl Fn(Txid) -> Result, ImplementationError>, ) -> MaybeFatalOrSuccessTransition { - let fallback_tx = self.state.fallback_tx(); - // If the fallback transaction included any non-SegWit inputs, then the transaction ID of // the Payjoin proposal is going to change when the sender signs their non-SegWit address // one more time. The receiver cannot monitor the transaction, and should conclude the session. if !self.proposal_txid_is_stable() { - return MaybeFatalOrSuccessTransition::success(SessionEvent::Closed( - SessionOutcome::PayjoinProposalSent, - )); + return self.payjoin_sent(); } - let payjoin_proposal = &self.state.psbt_context.payjoin_psbt; - let payjoin_txid = payjoin_proposal.unsigned_tx.compute_txid(); // If the sender is spending SegWit-only inputs, then the transaction ID of the Payjoin proposal // is not going to change when the sender signs it. So we can use the TXID to check the // network for the Payjoin proposal. - match find_transaction(payjoin_txid) { - Ok(Some(tx)) => { - let tx_id = tx.compute_txid(); - if tx_id != payjoin_txid { - return MaybeFatalOrSuccessTransition::transient( - Error::Implementation(ImplementationError::from( - format!("Payjoin transaction ID mismatch. Expected: {payjoin_txid}, Got: {tx_id}").as_str(), - )), - self, - ); - } - // Payjoin transaction with SegWit inputs was detected. Complete the session, - // recording the txid of the transaction that settled it. - return MaybeFatalOrSuccessTransition::success(SessionEvent::Closed( - SessionOutcome::Success(tx_id), - )); - } + match find_transaction(self.extract_payjoin_proposal_txid()) { + Ok(Some(tx)) => return self.payjoin_tx_exists(tx), Ok(None) => {} Err(e) => return MaybeFatalOrSuccessTransition::transient(Error::Implementation(e), self), @@ -1668,11 +1843,8 @@ impl Receiver { // If the Payjoin proposal was not found, check the fallback transaction, as it is // the second of two transactions whose IDs the receiver is aware of. - match find_transaction(fallback_tx.compute_txid()) { - Ok(Some(_)) => - return MaybeFatalOrSuccessTransition::success(SessionEvent::Closed( - SessionOutcome::FallbackBroadcasted, - )), + match find_transaction(self.extract_fallback_txid()) { + Ok(Some(_)) => return self.fallback_tx_exists(), Ok(None) => {} Err(e) => return MaybeFatalOrSuccessTransition::transient(Error::Implementation(e), self), @@ -1680,6 +1852,81 @@ impl Receiver { MaybeFatalOrSuccessTransition::no_results(self) } + + /// Extract the txid of the fallback transaction to look it up on the network. + /// + /// Call [`Receiver::fallback_tx_exists`] to conclude the session if the + /// fallback has been found on the network. + /// + /// Returns the fallback transaction's [`Txid`]. + pub fn extract_fallback_txid(&self) -> Txid { self.state.fallback_tx().compute_txid() } + + /// Extract the txid of the Payjoin proposal transaction to look it up on the network. + /// + /// Pass the Payjoin proposal transaction to [`Receiver::payjoin_tx_exists`] to + /// conclude the session. + /// + /// Returns the Payjoin proposal transaction's [`Txid`]. + pub fn extract_payjoin_proposal_txid(&self) -> Txid { + self.state.psbt_context.payjoin_psbt.clone().extract_tx_unchecked_fee_rate().compute_txid() + } + + /// Conclude the session when the Payjoin proposal's txid cannot be tracked. + /// + /// When the sender spends non-SegWit inputs the proposal txid changes once they sign, + /// so its broadcast cannot be monitored; see + /// [`Receiver::proposal_txid_is_stable`]. + /// + /// Returns a [`MaybeFatalOrSuccessTransition`] that, once successfully persisted, closes + /// the session with [`SessionOutcome::PayjoinProposalSent`]. + pub fn payjoin_sent(&self) -> MaybeFatalOrSuccessTransition { + MaybeFatalOrSuccessTransition::success(SessionEvent::Closed( + SessionOutcome::PayjoinProposalSent, + )) + } + + /// Conclude the session when the fallback transaction has been found on the network. + /// + /// Use [`Receiver::extract_fallback_txid`] to obtain the txid to look up. + /// + /// Returns a [`MaybeFatalOrSuccessTransition`] that, once successfully persisted, closes + /// the session with [`SessionOutcome::FallbackBroadcasted`]. + pub fn fallback_tx_exists(&self) -> MaybeFatalOrSuccessTransition { + MaybeFatalOrSuccessTransition::success(SessionEvent::Closed( + SessionOutcome::FallbackBroadcasted, + )) + } + + /// Conclude the session when the Payjoin proposal transaction has been found on the + /// network. + /// + /// Use [`Receiver::extract_payjoin_proposal_txid`] to obtain the txid to look + /// up, then pass the found transaction here. If its txid does not match the proposal, a + /// transient error is returned so the caller can retry. + /// + /// Returns a [`MaybeFatalOrSuccessTransition`] that, once successfully persisted, closes + /// the session with [`SessionOutcome::Success`]. + pub fn payjoin_tx_exists( + &self, + tx: Transaction, + ) -> MaybeFatalOrSuccessTransition { + let payjoin_txid = self.state.psbt_context.payjoin_psbt.unsigned_tx.compute_txid(); + let tx_id = tx.compute_txid(); + if tx_id != payjoin_txid { + return MaybeFatalOrSuccessTransition::transient( + Error::Implementation(ImplementationError::from( + format!( + "Payjoin transaction ID mismatch. Expected: {payjoin_txid}, Got: {tx_id}" + ) + .as_str(), + )), + self.clone(), + ); + } + // Payjoin transaction with SegWit inputs was detected. Complete the session, + // recording the txid of the transaction that settled it. + MaybeFatalOrSuccessTransition::success(SessionEvent::Closed(SessionOutcome::Success(tx_id))) + } } /// Derive a mailbox endpoint on a directory given a [`ShortId`]. From f4c8fd12708b3caef40fa3db692515b56b5b6338 Mon Sep 17 00:00:00 2001 From: xstoicunicornx Date: Wed, 13 May 2026 19:32:26 -0500 Subject: [PATCH 2/3] Add FFI bindings for non-blocking receive interface Expose the two-phase validation API from the previous commit through the FFI bindings layer. Update integration tests in C#, Dart, JavaScript, and Python to exercise both callback and nonblocking transition modes. --- payjoin-ffi/csharp/IntegrationTests.cs | 138 ++++- .../test/test_payjoin_integration_test.dart | 522 +++++++++++------- .../javascript/test/integration.test.ts | 128 ++++- .../test/test_payjoin_integration_test.py | 175 ++++-- payjoin-ffi/src/receive/mod.rs | 329 ++++++++++- 5 files changed, 992 insertions(+), 300 deletions(-) diff --git a/payjoin-ffi/csharp/IntegrationTests.cs b/payjoin-ffi/csharp/IntegrationTests.cs index 545a876ad..afa13a144 100644 --- a/payjoin-ffi/csharp/IntegrationTests.cs +++ b/payjoin-ffi/csharp/IntegrationTests.cs @@ -5,6 +5,12 @@ namespace Payjoin.Tests { + public enum TransitionMode + { + Callback, + Nonblocking, + } + /// /// End-to-end walkthrough of the BIP 77 (asynchronous payjoin) flow, and the /// usage reference the package README points at. @@ -280,6 +286,7 @@ private static InputPair[] GetInputs(RpcClient rpc) RpcClient receiverRpc, InMemoryReceiverPersister recvPersister, string ohttpRelay, + TransitionMode mode, CancellationToken cancellationToken) { var request = receiver.CreatePollRequest(ohttpRelay); @@ -304,7 +311,7 @@ private static InputPair[] GetInputs(RpcClient rpc) if (outcome is InitializedTransitionOutcome.Progress progress) { using var proposal = progress.Inner; - return await ProcessUncheckedProposal(proposal, receiverRpc, recvPersister); + return await ProcessUncheckedProposal(proposal, receiverRpc, recvPersister, mode); } throw new InvalidOperationException("Unknown initialized transition outcome"); @@ -322,12 +329,24 @@ private static InputPair[] GetInputs(RpcClient rpc) private Task ProcessUncheckedProposal( UncheckedOriginalPayload proposal, RpcClient receiverRpc, - InMemoryReceiverPersister recvPersister) + InMemoryReceiverPersister recvPersister, + TransitionMode mode) { - using var checkedTransition = proposal.CheckBroadcastSuitability(null, new MempoolAcceptanceCallback(receiverRpc)); - using var maybeInputsOwned = checkedTransition.Save(recvPersister); + MaybeInputsOwned maybeInputsOwned; + + if (mode == TransitionMode.Callback) + { + using var checkedTransition = proposal.CheckBroadcastSuitability(null, new MempoolAcceptanceCallback(receiverRpc)); + maybeInputsOwned = checkedTransition.Save(recvPersister); + } + else + { + var canBroadcast = new MempoolAcceptanceCallback(receiverRpc).Callback(proposal.ExtractTxToCheckBroadcastSuitability()); + using var checkedTransition = proposal.ApplyBroadcastSuitability(null, canBroadcast); + maybeInputsOwned = checkedTransition.Save(recvPersister); + } - return ProcessMaybeInputsOwned(maybeInputsOwned, receiverRpc, recvPersister); + return ProcessMaybeInputsOwned(maybeInputsOwned, receiverRpc, recvPersister, mode); } /// @@ -339,12 +358,26 @@ private Task ProcessUncheckedProposal( private Task ProcessMaybeInputsOwned( MaybeInputsOwned proposal, RpcClient receiverRpc, - InMemoryReceiverPersister recvPersister) + InMemoryReceiverPersister recvPersister, + TransitionMode mode) { - using var transition = proposal.CheckInputsNotOwned(new IsInputOwnedCallback(receiverRpc)); - using var maybeInputsSeen = transition.Save(recvPersister); + MaybeInputsSeen maybeInputsSeen; - return ProcessMaybeInputsSeen(maybeInputsSeen, receiverRpc, recvPersister); + if (mode == TransitionMode.Callback) + { + using var transition = proposal.CheckInputsNotOwned(new IsInputOwnedCallback(receiverRpc)); + maybeInputsSeen = transition.Save(recvPersister); + } + else + { + var markedChecklist = proposal.InputsOwnedChecklist() + .Select(item => item.Mark(new IsInputOwnedCallback(receiverRpc).Callback(item.Value()))) + .ToArray(); + using var transition = proposal.ApplyInputsOwnedChecklist(markedChecklist); + maybeInputsSeen = transition.Save(recvPersister); + } + + return ProcessMaybeInputsSeen(maybeInputsSeen, receiverRpc, recvPersister, mode); } /// @@ -354,12 +387,26 @@ private Task ProcessMaybeInputsOwned( private Task ProcessMaybeInputsSeen( MaybeInputsSeen proposal, RpcClient receiverRpc, - InMemoryReceiverPersister recvPersister) + InMemoryReceiverPersister recvPersister, + TransitionMode mode) { - using var transition = proposal.CheckNoInputsSeenBefore(new CheckInputsNotSeenCallback()); - using var outputsUnknown = transition.Save(recvPersister); + OutputsUnknown outputsUnknown; - return ProcessOutputsUnknown(outputsUnknown, receiverRpc, recvPersister); + if (mode == TransitionMode.Callback) + { + using var transition = proposal.CheckNoInputsSeenBefore(new CheckInputsNotSeenCallback()); + outputsUnknown = transition.Save(recvPersister); + } + else + { + var markedChecklist = proposal.InputsSeenChecklist() + .Select(item => item.Mark(new CheckInputsNotSeenCallback().Callback(item.Value()))) + .ToArray(); + using var transition = proposal.ApplyInputsSeenChecklist(markedChecklist); + outputsUnknown = transition.Save(recvPersister); + } + + return ProcessOutputsUnknown(outputsUnknown, receiverRpc, recvPersister, mode); } /// @@ -371,12 +418,26 @@ private Task ProcessMaybeInputsSeen( private Task ProcessOutputsUnknown( OutputsUnknown proposal, RpcClient receiverRpc, - InMemoryReceiverPersister recvPersister) + InMemoryReceiverPersister recvPersister, + TransitionMode mode) { - using var transition = proposal.IdentifyReceiverOutputs(new IsScriptOwnedCallback(receiverRpc)); - using var wantsOutputs = transition.Save(recvPersister); + WantsOutputs wantsOutputs; - return ProcessWantsOutputs(wantsOutputs, receiverRpc, recvPersister); + if (mode == TransitionMode.Callback) + { + using var transition = proposal.IdentifyReceiverOutputs(new IsScriptOwnedCallback(receiverRpc)); + wantsOutputs = transition.Save(recvPersister); + } + else + { + var markedChecklist = proposal.OutputsOwnedChecklist() + .Select(item => item.Mark(new IsScriptOwnedCallback(receiverRpc).Callback(item.Value()))) + .ToArray(); + using var transition = proposal.ApplyOutputsOwnedChecklist(markedChecklist); + wantsOutputs = transition.Save(recvPersister); + } + + return ProcessWantsOutputs(wantsOutputs, receiverRpc, recvPersister, mode); } /// @@ -387,12 +448,13 @@ private Task ProcessOutputsUnknown( private Task ProcessWantsOutputs( WantsOutputs proposal, RpcClient receiverRpc, - InMemoryReceiverPersister recvPersister) + InMemoryReceiverPersister recvPersister, + TransitionMode mode) { using var transition = proposal.CommitOutputs(); using var wantsInputs = transition.Save(recvPersister); - return ProcessWantsInputs(wantsInputs, receiverRpc, recvPersister); + return ProcessWantsInputs(wantsInputs, receiverRpc, recvPersister, mode); } /// @@ -404,13 +466,14 @@ private Task ProcessWantsOutputs( private Task ProcessWantsInputs( WantsInputs proposal, RpcClient receiverRpc, - InMemoryReceiverPersister recvPersister) + InMemoryReceiverPersister recvPersister, + TransitionMode mode) { using var contributed = proposal.ContributeInputs(GetInputs(receiverRpc)); using var transition = contributed.CommitInputs(); using var wantsFeeRange = transition.Save(recvPersister); - return ProcessWantsFeeRange(wantsFeeRange, receiverRpc, recvPersister); + return ProcessWantsFeeRange(wantsFeeRange, receiverRpc, recvPersister, mode); } /// @@ -423,12 +486,13 @@ private Task ProcessWantsInputs( private Task ProcessWantsFeeRange( WantsFeeRange proposal, RpcClient receiverRpc, - InMemoryReceiverPersister recvPersister) + InMemoryReceiverPersister recvPersister, + TransitionMode mode) { using var transition = proposal.ApplyFeeRange(1, 10); using var provisional = transition.Save(recvPersister); - return ProcessProvisionalProposal(provisional, receiverRpc, recvPersister); + return ProcessProvisionalProposal(provisional, receiverRpc, recvPersister, mode); } /// @@ -439,10 +503,22 @@ private Task ProcessWantsFeeRange( private Task ProcessProvisionalProposal( ProvisionalProposal proposal, RpcClient receiverRpc, - InMemoryReceiverPersister recvPersister) + InMemoryReceiverPersister recvPersister, + TransitionMode mode) { - using var transition = proposal.FinalizeProposal(new ProcessPsbtCallback(receiverRpc)); - var payjoinProposal = transition.Save(recvPersister); + PayjoinProposal payjoinProposal; + + if (mode == TransitionMode.Callback) + { + using var transition = proposal.FinalizeProposal(new ProcessPsbtCallback(receiverRpc)); + payjoinProposal = transition.Save(recvPersister); + } + else + { + var signedPsbt = new ProcessPsbtCallback(receiverRpc).Callback(proposal.PsbtToSign()); + using var transition = proposal.FinalizeSignedProposal(signedPsbt); + payjoinProposal = transition.Save(recvPersister); + } return Task.FromResult(payjoinProposal); } @@ -610,8 +686,10 @@ public void TestFfiValidation() /// directory carries every message between them, and both sides survive a /// restart because every state transition is saved to a persister first. /// - [Fact] - public async Task TestIntegrationV2ToV2() + [Theory] + [InlineData(TransitionMode.Callback)] + [InlineData(TransitionMode.Nonblocking)] + public async Task TestIntegrationV2ToV2(TransitionMode mode) { var cancellationToken = TestContext.Current.CancellationToken; @@ -651,7 +729,7 @@ public async Task TestIntegrationV2ToV2() // First poll: the sender has not posted anything yet, so the outcome // is Stasis and the helper returns null. A production receiver polls // on a timer. - var initial = await RetrieveReceiverProposal(session, receiver, recvPersister, ohttpRelay, cancellationToken); + var initial = await RetrieveReceiverProposal(session, receiver, recvPersister, ohttpRelay, mode, cancellationToken); Assert.Null(initial); // ***************************** @@ -699,7 +777,7 @@ public async Task TestIntegrationV2ToV2() // whole receiver pipeline (checks 1 through 4, output commit, input // contribution, fee range, signing; see the Process* methods above). // The result is the receiver-signed payjoin proposal. - using var payjoinProposal = await RetrieveReceiverProposal(session, receiver, recvPersister, ohttpRelay, cancellationToken); + using var payjoinProposal = await RetrieveReceiverProposal(session, receiver, recvPersister, ohttpRelay, mode, cancellationToken); Assert.NotNull(payjoinProposal); Assert.IsType(payjoinProposal); diff --git a/payjoin-ffi/dart/test/test_payjoin_integration_test.dart b/payjoin-ffi/dart/test/test_payjoin_integration_test.dart index 0c9acab25..632005062 100644 --- a/payjoin-ffi/dart/test/test_payjoin_integration_test.dart +++ b/payjoin-ffi/dart/test/test_payjoin_integration_test.dart @@ -16,6 +16,8 @@ late test_utils.BitcoindInstance bitcoind; late test_utils.RpcClient receiver; late test_utils.RpcClient sender; +enum TransitionMode { callback, nonblocking } + class MempoolAcceptanceCallback implements payjoin.CanBroadcast { final payjoin.RpcClient connection; @@ -233,91 +235,182 @@ List get_inputs(payjoin.RpcClient rpc_connection) { Future process_provisional_proposal( payjoin.ProvisionalProposal proposal, InMemoryReceiverPersister recv_persister, + TransitionMode mode, ) async { - final payjoin_proposal = proposal - .finalizeProposal(processPsbt: ProcessPsbtCallback(receiver)) - .save(persister: recv_persister); + final payjoin.PayjoinProposal payjoin_proposal; + if (mode == TransitionMode.callback) { + payjoin_proposal = proposal + .finalizeProposal(processPsbt: ProcessPsbtCallback(receiver)) + .save(persister: recv_persister); + } else { + final signed_psbt = ProcessPsbtCallback(receiver) + .callback(proposal.psbtToSign()); + payjoin_proposal = proposal + .finalizeSignedProposal(signedPsbt: signed_psbt) + .save(persister: recv_persister); + } return payjoin.PayjoinProposalReceiveSession(payjoin_proposal); } Future process_wants_fee_range( payjoin.WantsFeeRange proposal, InMemoryReceiverPersister recv_persister, + TransitionMode mode, ) async { final wants_fee_range = proposal .applyFeeRange(minFeeRateSatPerVb: 1, maxEffectiveFeeRateSatPerVb: 10) .save(persister: recv_persister); - return await process_provisional_proposal(wants_fee_range, recv_persister); + return await process_provisional_proposal( + wants_fee_range, + recv_persister, + mode, + ); } Future process_wants_inputs( payjoin.WantsInputs proposal, InMemoryReceiverPersister recv_persister, + TransitionMode mode, ) async { final provisional_proposal = proposal .contributeInputs(replacementInputs: get_inputs(receiver)) .commitInputs() .save(persister: recv_persister); - return await process_wants_fee_range(provisional_proposal, recv_persister); + return await process_wants_fee_range( + provisional_proposal, + recv_persister, + mode, + ); } Future process_wants_outputs( payjoin.WantsOutputs proposal, InMemoryReceiverPersister recv_persister, + TransitionMode mode, ) async { final wants_inputs = proposal.commitOutputs().save(persister: recv_persister); - return await process_wants_inputs(wants_inputs, recv_persister); + return await process_wants_inputs(wants_inputs, recv_persister, mode); } Future process_outputs_unknown( payjoin.OutputsUnknown proposal, InMemoryReceiverPersister recv_persister, + TransitionMode mode, ) async { - final wants_outputs = proposal - .identifyReceiverOutputs( - isReceiverOutput: IsScriptOwnedCallback(receiver), - ) - .save(persister: recv_persister); - return await process_wants_outputs(wants_outputs, recv_persister); + final payjoin.WantsOutputs wants_outputs; + if (mode == TransitionMode.callback) { + wants_outputs = proposal + .identifyReceiverOutputs( + isReceiverOutput: IsScriptOwnedCallback(receiver), + ) + .save(persister: recv_persister); + } else { + final markedChecklist = proposal + .outputsOwnedChecklist() + .map( + (item) => item.mark( + result: IsScriptOwnedCallback(receiver).callback(item.value()), + ), + ) + .toList(); + wants_outputs = proposal + .applyOutputsOwnedChecklist(markedChecklist: markedChecklist) + .save(persister: recv_persister); + } + return await process_wants_outputs(wants_outputs, recv_persister, mode); } Future process_maybe_inputs_seen( payjoin.MaybeInputsSeen proposal, InMemoryReceiverPersister recv_persister, + TransitionMode mode, ) async { - final outputs_unknown = proposal - .checkNoInputsSeenBefore(isKnown: CheckInputsNotSeenCallback(receiver)) - .save(persister: recv_persister); - return await process_outputs_unknown(outputs_unknown, recv_persister); + final payjoin.OutputsUnknown outputs_unknown; + if (mode == TransitionMode.callback) { + outputs_unknown = proposal + .checkNoInputsSeenBefore(isKnown: CheckInputsNotSeenCallback(receiver)) + .save(persister: recv_persister); + } else { + final markedChecklist = proposal + .inputsSeenChecklist() + .map( + (item) => item.mark( + result: CheckInputsNotSeenCallback(receiver).callback(item.value()), + ), + ) + .toList(); + outputs_unknown = proposal + .applyInputsSeenChecklist(markedChecklist: markedChecklist) + .save(persister: recv_persister); + } + return await process_outputs_unknown(outputs_unknown, recv_persister, mode); } Future process_maybe_inputs_owned( payjoin.MaybeInputsOwned proposal, InMemoryReceiverPersister recv_persister, + TransitionMode mode, ) async { - final maybe_inputs_owned = proposal - .checkInputsNotOwned(isOwned: IsInputOwnedCallback(receiver)) - .save(persister: recv_persister); - return await process_maybe_inputs_seen(maybe_inputs_owned, recv_persister); + final payjoin.MaybeInputsSeen maybe_inputs_owned; + if (mode == TransitionMode.callback) { + maybe_inputs_owned = proposal + .checkInputsNotOwned(isOwned: IsInputOwnedCallback(receiver)) + .save(persister: recv_persister); + } else { + final markedChecklist = proposal + .inputsOwnedChecklist() + .map( + (item) => item.mark( + result: IsInputOwnedCallback(receiver).callback(item.value()), + ), + ) + .toList(); + maybe_inputs_owned = proposal + .applyInputsOwnedChecklist(markedChecklist: markedChecklist) + .save(persister: recv_persister); + } + return await process_maybe_inputs_seen( + maybe_inputs_owned, + recv_persister, + mode, + ); } Future process_unchecked_proposal( payjoin.UncheckedOriginalPayload proposal, InMemoryReceiverPersister recv_persister, + TransitionMode mode, ) async { - final unchecked_proposal = proposal - .checkBroadcastSuitability( - minFeeRateSatPerKwu: null, - canBroadcast: MempoolAcceptanceCallback(receiver), - ) - .save(persister: recv_persister); - return await process_maybe_inputs_owned(unchecked_proposal, recv_persister); + final payjoin.MaybeInputsOwned unchecked_proposal; + if (mode == TransitionMode.callback) { + unchecked_proposal = proposal + .checkBroadcastSuitability( + minFeeRateSatPerKwu: null, + canBroadcast: MempoolAcceptanceCallback(receiver), + ) + .save(persister: recv_persister); + } else { + final is_broadcast_suitable = MempoolAcceptanceCallback(receiver) + .callback(proposal.extractTxToCheckBroadcastSuitability()); + unchecked_proposal = proposal + .applyBroadcastSuitability( + minFeeRateSatPerKwu: null, + isBroadcastSuitable: is_broadcast_suitable, + ) + .save(persister: recv_persister); + } + return await process_maybe_inputs_owned( + unchecked_proposal, + recv_persister, + mode, + ); } Future retrieve_receiver_proposal( payjoin.Initialized receiver, InMemoryReceiverPersister recv_persister, String ohttp_relay, + TransitionMode mode, ) async { var agent = http.Client(); var request = receiver.createPollRequest(ohttpRelay: ohttp_relay); @@ -334,7 +427,7 @@ Future retrieve_receiver_proposal( return null; } else if (res is payjoin.ProgressInitializedTransitionOutcome) { var proposal = res.inner; - return await process_unchecked_proposal(proposal, recv_persister); + return await process_unchecked_proposal(proposal, recv_persister, mode); } throw Exception("Unknown initialized transition outcome: $res"); @@ -344,12 +437,14 @@ Future process_receiver_proposal( payjoin.ReceiveSession receiver, InMemoryReceiverPersister recv_persister, String ohttp_relay, + TransitionMode mode, ) async { if (receiver is payjoin.InitializedReceiveSession) { var res = await retrieve_receiver_proposal( receiver.inner, recv_persister, ohttp_relay, + mode, ); if (res == null) { return null; @@ -358,25 +453,41 @@ Future process_receiver_proposal( } if (receiver is payjoin.UncheckedOriginalPayloadReceiveSession) { - return await process_unchecked_proposal(receiver.inner, recv_persister); + return await process_unchecked_proposal( + receiver.inner, + recv_persister, + mode, + ); } if (receiver is payjoin.MaybeInputsOwnedReceiveSession) { - return await process_maybe_inputs_owned(receiver.inner, recv_persister); + return await process_maybe_inputs_owned( + receiver.inner, + recv_persister, + mode, + ); } if (receiver is payjoin.MaybeInputsSeenReceiveSession) { - return await process_maybe_inputs_seen(receiver.inner, recv_persister); + return await process_maybe_inputs_seen( + receiver.inner, + recv_persister, + mode, + ); } if (receiver is payjoin.OutputsUnknownReceiveSession) { - return await process_outputs_unknown(receiver.inner, recv_persister); + return await process_outputs_unknown(receiver.inner, recv_persister, mode); } if (receiver is payjoin.WantsOutputsReceiveSession) { - return await process_wants_outputs(receiver.inner, recv_persister); + return await process_wants_outputs(receiver.inner, recv_persister, mode); } if (receiver is payjoin.WantsInputsReceiveSession) { - return await process_wants_inputs(receiver.inner, recv_persister); + return await process_wants_inputs(receiver.inner, recv_persister, mode); } if (receiver is payjoin.ProvisionalProposalReceiveSession) { - return await process_provisional_proposal(receiver.inner, recv_persister); + return await process_provisional_proposal( + receiver.inner, + recv_persister, + mode, + ); } if (receiver is payjoin.PayjoinProposalReceiveSession) { return receiver; @@ -385,6 +496,167 @@ Future process_receiver_proposal( throw Exception("Unknown receiver state: $receiver"); } +Future run_integration_v2_to_v2(TransitionMode mode) async { + env = test_utils.initBitcoindSenderReceiver(); + bitcoind = env.getBitcoind(); + receiver = env.getReceiver(); + sender = env.getSender(); + var receiver_address = + jsonDecode(receiver.call(method: "getnewaddress", params: [])) as String; + var services = test_utils.TestServices.initialize(); + + services.waitForServicesReady(); + var directory = services.directoryUrl(); + var ohttp_keys = services.fetchOhttpKeys(); + var ohttp_relay = services.ohttpRelayUrl(); + var agent = http.Client(); + + // ********************** + // Inside the Receiver: + var recv_persister = InMemoryReceiverPersister(); + var sender_persister = InMemorySenderPersister(); + var session = create_receiver_context( + receiver_address, + directory, + ohttp_keys, + recv_persister, + ); + var process_response = await process_receiver_proposal( + payjoin.InitializedReceiveSession(session), + recv_persister, + ohttp_relay, + mode, + ); + expect(process_response, isNull); + + // ********************** + // Inside the Sender: + // Create a funded PSBT (not broadcasted) to address with amount given in the pj_uri + var pj_uri = session.pjUri(); + var psbt = build_sweep_psbt(sender, pj_uri); + payjoin.WithReplyKey req_ctx = payjoin.SenderBuilder(psbt: psbt, uri: pj_uri) + .buildRecommended(minFeeRateSatPerKwu: 1000) + .save(persister: sender_persister); + payjoin.RequestOhttpContext request = req_ctx.createV2PostRequest( + ohttpRelay: ohttp_relay, + ); + var response = await agent.post( + Uri.parse(request.request.url), + headers: {"Content-Type": request.request.contentType}, + body: request.request.body, + ); + payjoin.PollingForProposal send_ctx = req_ctx + .processResponse(response: response.bodyBytes, postCtx: request.ohttpCtx) + .save(persister: sender_persister); + // POST Original PSBT + + // ********************** + // Inside the Receiver: + + // GET fallback psbt + payjoin.ReceiveSession? payjoin_proposal = await process_receiver_proposal( + payjoin.InitializedReceiveSession(session), + recv_persister, + ohttp_relay, + mode, + ); + expect(payjoin_proposal, isNotNull); + expect(payjoin_proposal, isA()); + + payjoin.PayjoinProposal proposal = + (payjoin_proposal as payjoin.PayjoinProposalReceiveSession).inner; + payjoin.RequestResponse request_response = proposal.createPostRequest( + ohttpRelay: ohttp_relay, + ); + var fallback_response = await agent.post( + Uri.parse(request_response.request.url), + headers: {"Content-Type": request_response.request.contentType}, + body: request_response.request.body, + ); + proposal.processResponse( + body: fallback_response.bodyBytes, + ohttpContext: request_response.clientResponse, + ); + + // ********************** + // Inside the Sender: + // Sender checks, signs, finalizes, extracts, and broadcasts + // Replay post fallback to get the response + payjoin.PollingForProposalTransitionOutcome? poll_outcome; + var attempts = 0; + while (true) { + payjoin.RequestOhttpContext ohttp_context_request = send_ctx + .createPollRequest(ohttpRelay: ohttp_relay); + var final_response = await agent.post( + Uri.parse(ohttp_context_request.request.url), + headers: {"Content-Type": ohttp_context_request.request.contentType}, + body: ohttp_context_request.request.body, + ); + poll_outcome = send_ctx + .processResponse( + response: final_response.bodyBytes, + ohttpCtx: ohttp_context_request.ohttpCtx, + ) + .save(persister: sender_persister); + + if (poll_outcome is payjoin.ProgressPollingForProposalTransitionOutcome) { + break; + } + + attempts += 1; + if (attempts >= 3) { + // Receiver not ready yet; mirror Python's tolerant polling. + return; + } + } + + final progressOutcome = + poll_outcome as payjoin.ProgressPollingForProposalTransitionOutcome; + var payjoin_psbt = jsonDecode( + sender.call( + method: "walletprocesspsbt", + params: [progressOutcome.psbtBase64], + ), + )["psbt"]; + var final_psbt = jsonDecode( + sender.call( + method: "finalizepsbt", + params: [payjoin_psbt, jsonEncode(false)], + ), + )["psbt"]; + var final_tx_hex = jsonDecode( + sender.call(method: "finalizepsbt", params: [final_psbt, jsonEncode(true)]), + )["hex"]; + sender.call(method: "sendrawtransaction", params: [jsonEncode(final_tx_hex)]); + + // Check resulting transaction and balances + var decodedTx = jsonDecode( + sender.call( + method: "decoderawtransaction", + params: [jsonEncode(final_tx_hex)], + ), + ); + var network_fees = + (jsonDecode( + sender.call( + method: "decodepsbt", + params: [jsonEncode(final_psbt)], + ), + )["fee"] + as num) + .toDouble(); + // Sender sent the entire value of their utxo to the receiver (minus fees) + expect(decodedTx["vin"].length, 2); + expect(decodedTx["vout"].length, 1); + expect( + jsonDecode( + receiver.call(method: "getbalances", params: []), + )["mine"]["untrusted_pending"], + 100 - network_fees, + ); + expect(jsonDecode(sender.call(method: "getbalance", params: [])), 0.0); +} + void main() { group('fetchOhttpKeys', () { test('fetches and decodes keys via relay proxy', () async { @@ -494,176 +766,16 @@ void main() { ); }); - test('Test integration v2 to v2', () async { - env = test_utils.initBitcoindSenderReceiver(); - bitcoind = env.getBitcoind(); - receiver = env.getReceiver(); - sender = env.getSender(); - var receiver_address = jsonDecode( - receiver.call(method: "getnewaddress", params: []), - ) as String; - var services = test_utils.TestServices.initialize(); - - services.waitForServicesReady(); - var directory = services.directoryUrl(); - var ohttp_keys = services.fetchOhttpKeys(); - var ohttp_relay = services.ohttpRelayUrl(); - var agent = http.Client(); - - // ********************** - // Inside the Receiver: - var recv_persister = InMemoryReceiverPersister(); - var sender_persister = InMemorySenderPersister(); - var session = create_receiver_context( - receiver_address, - directory, - ohttp_keys, - recv_persister, - ); - var process_response = await process_receiver_proposal( - payjoin.InitializedReceiveSession(session), - recv_persister, - ohttp_relay, - ); - expect(process_response, isNull); - - // ********************** - // Inside the Sender: - // Create a funded PSBT (not broadcasted) to address with amount given in the pj_uri - var pj_uri = session.pjUri(); - var psbt = build_sweep_psbt(sender, pj_uri); - payjoin.WithReplyKey req_ctx = - payjoin.SenderBuilder(psbt: psbt, uri: pj_uri) - .buildRecommended(minFeeRateSatPerKwu: 1000) - .save(persister: sender_persister); - payjoin.RequestOhttpContext request = req_ctx.createV2PostRequest( - ohttpRelay: ohttp_relay, - ); - var response = await agent.post( - Uri.parse(request.request.url), - headers: {"Content-Type": request.request.contentType}, - body: request.request.body, - ); - payjoin.PollingForProposal send_ctx = req_ctx - .processResponse( - response: response.bodyBytes, - postCtx: request.ohttpCtx, - ) - .save(persister: sender_persister); - // POST Original PSBT - - // ********************** - // Inside the Receiver: - - // GET fallback psbt - payjoin.ReceiveSession? payjoin_proposal = - await process_receiver_proposal( - payjoin.InitializedReceiveSession(session), - recv_persister, - ohttp_relay, - ); - expect(payjoin_proposal, isNotNull); - expect(payjoin_proposal, isA()); - - payjoin.PayjoinProposal proposal = - (payjoin_proposal as payjoin.PayjoinProposalReceiveSession).inner; - payjoin.RequestResponse request_response = proposal.createPostRequest( - ohttpRelay: ohttp_relay, - ); - var fallback_response = await agent.post( - Uri.parse(request_response.request.url), - headers: {"Content-Type": request_response.request.contentType}, - body: request_response.request.body, - ); - proposal.processResponse( - body: fallback_response.bodyBytes, - ohttpContext: request_response.clientResponse, - ); - - // ********************** - // Inside the Sender: - // Sender checks, signs, finalizes, extracts, and broadcasts - // Replay post fallback to get the response - payjoin.PollingForProposalTransitionOutcome? poll_outcome; - var attempts = 0; - while (true) { - payjoin.RequestOhttpContext ohttp_context_request = send_ctx - .createPollRequest(ohttpRelay: ohttp_relay); - var final_response = await agent.post( - Uri.parse(ohttp_context_request.request.url), - headers: {"Content-Type": ohttp_context_request.request.contentType}, - body: ohttp_context_request.request.body, - ); - poll_outcome = send_ctx - .processResponse( - response: final_response.bodyBytes, - ohttpCtx: ohttp_context_request.ohttpCtx, - ) - .save(persister: sender_persister); - - if (poll_outcome - is payjoin.ProgressPollingForProposalTransitionOutcome) { - break; - } - - attempts += 1; - if (attempts >= 3) { - // Receiver not ready yet; mirror Python's tolerant polling. - return; - } - } - - final progressOutcome = - poll_outcome as payjoin.ProgressPollingForProposalTransitionOutcome; - var payjoin_psbt = jsonDecode( - sender.call( - method: "walletprocesspsbt", - params: [progressOutcome.psbtBase64], - ), - )["psbt"]; - var final_psbt = jsonDecode( - sender.call( - method: "finalizepsbt", - params: [payjoin_psbt, jsonEncode(false)], - ), - )["psbt"]; - var final_tx_hex = jsonDecode( - sender.call( - method: "finalizepsbt", - params: [final_psbt, jsonEncode(true)], - ), - )["hex"]; - sender.call( - method: "sendrawtransaction", - params: [jsonEncode(final_tx_hex)], - ); + test( + 'Test integration v2 to v2 (callback)', + () async => run_integration_v2_to_v2(TransitionMode.callback), + timeout: const Timeout(Duration(minutes: 5)), + ); - // Check resulting transaction and balances - var decodedTx = jsonDecode( - sender.call( - method: "decoderawtransaction", - params: [jsonEncode(final_tx_hex)], - ), - ); - var network_fees = - (jsonDecode( - sender.call( - method: "decodepsbt", - params: [jsonEncode(final_psbt)], - ), - )["fee"] - as num) - .toDouble(); - // Sender sent the entire value of their utxo to the receiver (minus fees) - expect(decodedTx["vin"].length, 2); - expect(decodedTx["vout"].length, 1); - expect( - jsonDecode( - receiver.call(method: "getbalances", params: []), - )["mine"]["untrusted_pending"], - 100 - network_fees, - ); - expect(jsonDecode(sender.call(method: "getbalance", params: [])), 0.0); - }, timeout: const Timeout(Duration(minutes: 5))); + test( + 'Test integration v2 to v2 (nonblocking)', + () async => run_integration_v2_to_v2(TransitionMode.nonblocking), + timeout: const Timeout(Duration(minutes: 5)), + ); }); } diff --git a/payjoin-ffi/javascript/test/integration.test.ts b/payjoin-ffi/javascript/test/integration.test.ts index fb607b83f..12799921a 100644 --- a/payjoin-ffi/javascript/test/integration.test.ts +++ b/payjoin-ffi/javascript/test/integration.test.ts @@ -31,6 +31,8 @@ interface Utxo { scriptPubKey: string; } +type TransitionMode = "callback" | "nonblocking"; + type PayjoinModule = typeof nodejsPayjoin; const webPayjoin = webPayjoinModule as unknown as PayjoinModule; @@ -253,13 +255,23 @@ class ReceiverProcessor { private readonly payjoin: PayjoinModule, private readonly receiver: testUtils.RpcClient, private readonly recvPersister: InMemoryReceiverPersister, + private readonly mode: TransitionMode, ) {} private async processProvisionalProposal( proposal: PJ<"ProvisionalProposal">, ): Promise> { + if (this.mode === "callback") { + return proposal + .finalizeProposal(new ProcessPsbtCallback(this.receiver)) + .save(this.recvPersister) as PJ<"PayjoinProposal">; + } + + const signedPsbt = new ProcessPsbtCallback(this.receiver).callback( + proposal.psbtToSign(), + ); return proposal - .finalizeProposal(new ProcessPsbtCallback(this.receiver)) + .finalizeSignedProposal(signedPsbt) .save(this.recvPersister) as PJ<"PayjoinProposal">; } @@ -294,41 +306,109 @@ class ReceiverProcessor { private async processOutputsUnknown( proposal: PJ<"OutputsUnknown">, ): Promise> { - const wantsOutputs = proposal - .identifyReceiverOutputs(new IsScriptOwnedCallback(this.receiver)) - .save(this.recvPersister) as PJ<"WantsOutputs">; + let wantsOutputs: PJ<"WantsOutputs">; + + if (this.mode === "callback") { + wantsOutputs = proposal + .identifyReceiverOutputs( + new IsScriptOwnedCallback(this.receiver), + ) + .save(this.recvPersister) as PJ<"WantsOutputs">; + } else { + const markedChecklist = proposal + .outputsOwnedChecklist() + .map((item) => + item.mark( + new IsScriptOwnedCallback(this.receiver).callback( + item.value(), + ), + ), + ); + wantsOutputs = proposal + .applyOutputsOwnedChecklist(markedChecklist) + .save(this.recvPersister) as PJ<"WantsOutputs">; + } + return this.processWantsOutputs(wantsOutputs); } private async processMaybeInputsSeen( proposal: PJ<"MaybeInputsSeen">, ): Promise> { - const outputsUnknown = proposal - .checkNoInputsSeenBefore( - new CheckInputsNotSeenCallback(this.receiver), - ) - .save(this.recvPersister) as PJ<"OutputsUnknown">; + let outputsUnknown: PJ<"OutputsUnknown">; + + if (this.mode === "callback") { + outputsUnknown = proposal + .checkNoInputsSeenBefore( + new CheckInputsNotSeenCallback(this.receiver), + ) + .save(this.recvPersister) as PJ<"OutputsUnknown">; + } else { + const markedChecklist = proposal + .inputsSeenChecklist() + .map((item) => + item.mark( + new CheckInputsNotSeenCallback(this.receiver).callback( + item.value(), + ), + ), + ); + outputsUnknown = proposal + .applyInputsSeenChecklist(markedChecklist) + .save(this.recvPersister) as PJ<"OutputsUnknown">; + } + return this.processOutputsUnknown(outputsUnknown); } private async processMaybeInputsOwned( proposal: nodejsPayjoin.MaybeInputsOwned, ): Promise> { - const maybeInputsSeen = proposal - .checkInputsNotOwned(new IsInputOwnedCallback(this.receiver)) - .save(this.recvPersister) as PJ<"MaybeInputsSeen">; + let maybeInputsSeen: PJ<"MaybeInputsSeen">; + + if (this.mode === "callback") { + maybeInputsSeen = proposal + .checkInputsNotOwned(new IsInputOwnedCallback(this.receiver)) + .save(this.recvPersister) as PJ<"MaybeInputsSeen">; + } else { + const markedChecklist = proposal + .inputsOwnedChecklist() + .map((item) => + item.mark( + new IsInputOwnedCallback(this.receiver).callback( + item.value(), + ), + ), + ); + maybeInputsSeen = proposal + .applyInputsOwnedChecklist(markedChecklist) + .save(this.recvPersister) as PJ<"MaybeInputsSeen">; + } + return this.processMaybeInputsSeen(maybeInputsSeen); } private async processUncheckedProposal( proposal: PJ<"UncheckedOriginalPayload">, ): Promise> { - const maybeInputsOwned = proposal - .checkBroadcastSuitability( - undefined, - new MempoolAcceptanceCallback(this.receiver), - ) - .save(this.recvPersister) as PJ<"MaybeInputsOwned">; + let maybeInputsOwned: PJ<"MaybeInputsOwned">; + + if (this.mode === "callback") { + maybeInputsOwned = proposal + .checkBroadcastSuitability( + undefined, + new MempoolAcceptanceCallback(this.receiver), + ) + .save(this.recvPersister) as PJ<"MaybeInputsOwned">; + } else { + const canBroadcastResult = new MempoolAcceptanceCallback( + this.receiver, + ).callback(proposal.extractTxToCheckBroadcastSuitability()); + maybeInputsOwned = proposal + .applyBroadcastSuitability(undefined, canBroadcastResult) + .save(this.recvPersister) as PJ<"MaybeInputsOwned">; + } + return this.processMaybeInputsOwned(maybeInputsOwned); } @@ -519,7 +599,10 @@ function testFfiValidation(payjoin: PayjoinModule): void { }, /AmountOutOfRange/); } -async function testIntegrationV2ToV2(payjoin: PayjoinModule): Promise { +async function testIntegrationV2ToV2( + payjoin: PayjoinModule, + mode: TransitionMode, +): Promise { const env = testUtils.initBitcoindSenderReceiver(); const receiver = env.getReceiver(); const sender = env.getSender(); @@ -541,6 +624,7 @@ async function testIntegrationV2ToV2(payjoin: PayjoinModule): Promise { payjoin, receiver, recvPersister, + mode, ); const senderPersister = new InMemorySenderPersister(); @@ -672,11 +756,13 @@ async function testIntegrationV2ToV2(payjoin: PayjoinModule): Promise { async function runTests(): Promise { await nodejsUniffiInitAsync(); testFfiValidation(nodejsPayjoin); - await testIntegrationV2ToV2(nodejsPayjoin); + await testIntegrationV2ToV2(nodejsPayjoin, "callback"); + await testIntegrationV2ToV2(nodejsPayjoin, "nonblocking"); await webUniffiInitAsync(); testFfiValidation(webPayjoin); - await testIntegrationV2ToV2(webPayjoin); + await testIntegrationV2ToV2(webPayjoin, "callback"); + await testIntegrationV2ToV2(webPayjoin, "nonblocking"); } runTests().catch((error: unknown) => { diff --git a/payjoin-ffi/python/test/test_payjoin_integration_test.py b/payjoin-ffi/python/test/test_payjoin_integration_test.py index b08364f28..11ef4e156 100644 --- a/payjoin-ffi/python/test/test_payjoin_integration_test.py +++ b/payjoin-ffi/python/test/test_payjoin_integration_test.py @@ -2,7 +2,7 @@ import sys import httpx import json -from typing import cast, Protocol, Any +from typing import cast, Protocol, Any, Literal from payjoin import * from payjoin.http import fetch_ohttp_keys @@ -22,6 +22,9 @@ class HasInner(Protocol): inner: Any +TransitionMode = Literal["callback", "nonblocking"] + + class TestPayjoin(unittest.IsolatedAsyncioTestCase): @classmethod def setUpClass(cls): @@ -92,12 +95,14 @@ async def process_receiver_proposal( receiver: ReceiveSession, recv_persister: InMemoryReceiverPersister, ohttp_relay: str, + mode: TransitionMode, ) -> Optional[ReceiveSession.PAYJOIN_PROPOSAL]: if receiver.is_INITIALIZED(): res = await self.retrieve_receiver_proposal( cast(ReceiveSession.INITIALIZED, receiver).inner, recv_persister, ohttp_relay, + mode, ) if res is None: return None @@ -107,35 +112,49 @@ async def process_receiver_proposal( return await self.process_unchecked_proposal( cast(ReceiveSession.UNCHECKED_ORIGINAL_PAYLOAD, receiver).inner, recv_persister, + mode, ) if receiver.is_MAYBE_INPUTS_OWNED(): return await self.process_maybe_inputs_owned( - cast(ReceiveSession.MAYBE_INPUTS_OWNED, receiver).inner, recv_persister + cast(ReceiveSession.MAYBE_INPUTS_OWNED, receiver).inner, + recv_persister, + mode, ) if receiver.is_MAYBE_INPUTS_SEEN(): return await self.process_maybe_inputs_seen( - cast(ReceiveSession.MAYBE_INPUTS_SEEN, receiver).inner, recv_persister + cast(ReceiveSession.MAYBE_INPUTS_SEEN, receiver).inner, + recv_persister, + mode, ) if receiver.is_OUTPUTS_UNKNOWN(): return await self.process_outputs_unknown( - cast(ReceiveSession.OUTPUTS_UNKNOWN, receiver).inner, recv_persister + cast(ReceiveSession.OUTPUTS_UNKNOWN, receiver).inner, + recv_persister, + mode, ) if receiver.is_WANTS_OUTPUTS(): return await self.process_wants_outputs( - cast(ReceiveSession.WANTS_OUTPUTS, receiver).inner, recv_persister + cast(ReceiveSession.WANTS_OUTPUTS, receiver).inner, + recv_persister, + mode, ) if receiver.is_WANTS_INPUTS(): return await self.process_wants_inputs( - cast(ReceiveSession.WANTS_INPUTS, receiver).inner, recv_persister + cast(ReceiveSession.WANTS_INPUTS, receiver).inner, + recv_persister, + mode, ) if receiver.is_WANTS_FEE_RANGE(): return await self.process_wants_fee_range( - cast(ReceiveSession.WANTS_FEE_RANGE, receiver).inner, recv_persister + cast(ReceiveSession.WANTS_FEE_RANGE, receiver).inner, + recv_persister, + mode, ) if receiver.is_PROVISIONAL_PROPOSAL(): return await self.process_provisional_proposal( cast(ReceiveSession.PROVISIONAL_PROPOSAL, receiver).inner, recv_persister, + mode, ) if receiver.is_PAYJOIN_PROPOSAL(): return cast(ReceiveSession.PAYJOIN_PROPOSAL, receiver) @@ -161,6 +180,7 @@ async def retrieve_receiver_proposal( receiver: Initialized, recv_persister: InMemoryReceiverPersister, ohttp_relay: str, + mode: TransitionMode, ): agent = httpx.AsyncClient() request: RequestResponse = receiver.create_poll_request(ohttp_relay) @@ -175,80 +195,155 @@ async def retrieve_receiver_proposal( if res.is_STASIS(): return None return await self.process_unchecked_proposal( - cast(ReceiveSession.UNCHECKED_ORIGINAL_PAYLOAD, res).inner, recv_persister + cast(ReceiveSession.UNCHECKED_ORIGINAL_PAYLOAD, res).inner, + recv_persister, + mode, ) async def process_unchecked_proposal( self, proposal: UncheckedOriginalPayload, recv_persister: InMemoryReceiverPersister, + mode: TransitionMode, ): - receiver = proposal.check_broadcast_suitability( - None, MempoolAcceptanceCallback(self.receiver) - ).save(recv_persister) - return await self.process_maybe_inputs_owned(receiver, recv_persister) + if mode == "callback": + receiver = proposal.check_broadcast_suitability( + None, MempoolAcceptanceCallback(self.receiver) + ).save(recv_persister) + else: + can_broadcast = MempoolAcceptanceCallback(self.receiver).callback( + proposal.extract_tx_to_check_broadcast_suitability() + ) + receiver = proposal.apply_broadcast_suitability(None, can_broadcast).save( + recv_persister + ) + return await self.process_maybe_inputs_owned(receiver, recv_persister, mode) async def process_maybe_inputs_owned( self, proposal: MaybeInputsOwned, recv_persister: InMemoryReceiverPersister, + mode: TransitionMode, ): - maybe_inputs_owned = proposal.check_inputs_not_owned( - IsInputOwnedCallback(self.receiver) - ).save(recv_persister) - return await self.process_maybe_inputs_seen(maybe_inputs_owned, recv_persister) + if mode == "callback": + maybe_inputs_owned = proposal.check_inputs_not_owned( + IsInputOwnedCallback(self.receiver) + ).save(recv_persister) + else: + marked_checklist = [ + item.mark(IsInputOwnedCallback(self.receiver).callback(item.value())) + for item in proposal.inputs_owned_checklist() + ] + maybe_inputs_owned = proposal.apply_inputs_owned_checklist( + marked_checklist + ).save(recv_persister) + return await self.process_maybe_inputs_seen( + maybe_inputs_owned, recv_persister, mode + ) async def process_maybe_inputs_seen( - self, proposal: MaybeInputsSeen, recv_persister: InMemoryReceiverPersister + self, + proposal: MaybeInputsSeen, + recv_persister: InMemoryReceiverPersister, + mode: TransitionMode, ): - outputs_unknown = proposal.check_no_inputs_seen_before( - CheckInputsNotSeenCallback(self.receiver) - ).save(recv_persister) - return await self.process_outputs_unknown(outputs_unknown, recv_persister) + if mode == "callback": + outputs_unknown = proposal.check_no_inputs_seen_before( + CheckInputsNotSeenCallback(self.receiver) + ).save(recv_persister) + else: + marked_checklist = [ + item.mark( + CheckInputsNotSeenCallback(self.receiver).callback(item.value()) + ) + for item in proposal.inputs_seen_checklist() + ] + outputs_unknown = proposal.apply_inputs_seen_checklist( + marked_checklist + ).save(recv_persister) + return await self.process_outputs_unknown(outputs_unknown, recv_persister, mode) async def process_outputs_unknown( - self, proposal: OutputsUnknown, recv_persister: InMemoryReceiverPersister + self, + proposal: OutputsUnknown, + recv_persister: InMemoryReceiverPersister, + mode: TransitionMode, ): - wants_outputs = proposal.identify_receiver_outputs( - IsScriptOwnedCallback(self.receiver) - ).save(recv_persister) - return await self.process_wants_outputs(wants_outputs, recv_persister) + if mode == "callback": + wants_outputs = proposal.identify_receiver_outputs( + IsScriptOwnedCallback(self.receiver) + ).save(recv_persister) + else: + marked_checklist = [ + item.mark(IsScriptOwnedCallback(self.receiver).callback(item.value())) + for item in proposal.outputs_owned_checklist() + ] + wants_outputs = proposal.apply_outputs_owned_checklist( + marked_checklist + ).save(recv_persister) + return await self.process_wants_outputs(wants_outputs, recv_persister, mode) async def process_wants_outputs( - self, proposal: WantsOutputs, recv_persister: InMemoryReceiverPersister + self, + proposal: WantsOutputs, + recv_persister: InMemoryReceiverPersister, + mode: TransitionMode, ): wants_inputs = proposal.commit_outputs().save(recv_persister) - return await self.process_wants_inputs(wants_inputs, recv_persister) + return await self.process_wants_inputs(wants_inputs, recv_persister, mode) async def process_wants_inputs( - self, proposal: WantsInputs, recv_persister: InMemoryReceiverPersister + self, + proposal: WantsInputs, + recv_persister: InMemoryReceiverPersister, + mode: TransitionMode, ): provisional_proposal = ( proposal.contribute_inputs(get_inputs(self.receiver)) .commit_inputs() .save(recv_persister) ) - return await self.process_wants_fee_range(provisional_proposal, recv_persister) + return await self.process_wants_fee_range( + provisional_proposal, recv_persister, mode + ) async def process_wants_fee_range( - self, proposal: WantsFeeRange, recv_persister: InMemoryReceiverPersister + self, + proposal: WantsFeeRange, + recv_persister: InMemoryReceiverPersister, + mode: TransitionMode, ): provisional_proposal = proposal.apply_fee_range(1, 10).save(recv_persister) return await self.process_provisional_proposal( - provisional_proposal, recv_persister + provisional_proposal, recv_persister, mode ) async def process_provisional_proposal( self, proposal: ProvisionalProposal, recv_persister: InMemoryReceiverPersister, + mode: TransitionMode, ): - payjoin_proposal = proposal.finalize_proposal( - ProcessPsbtCallback(self.receiver) - ).save(recv_persister) + if mode == "callback": + payjoin_proposal = proposal.finalize_proposal( + ProcessPsbtCallback(self.receiver) + ).save(recv_persister) + else: + signed_psbt = ProcessPsbtCallback(self.receiver).callback( + proposal.psbt_to_sign() + ) + payjoin_proposal = proposal.finalize_signed_proposal(signed_psbt).save( + recv_persister + ) return ReceiveSession.PAYJOIN_PROPOSAL(payjoin_proposal) - async def test_integration_v2_to_v2(self): + def setUp(self): + sender_address = json.loads(self.sender.call("getnewaddress", [])) + self.sender.call( + "generatetoaddress", [json.dumps(101), json.dumps(sender_address)] + ) + + async def _run_integration_v2_to_v2(self, mode: TransitionMode): try: receiver_address = json.loads(self.receiver.call("getnewaddress", [])) init_tracing() @@ -271,6 +366,7 @@ async def test_integration_v2_to_v2(self): cast(ReceiveSession, ReceiveSession.INITIALIZED(session)), recv_persister, ohttp_relay, + mode, ) self.assertIsNone(process_response) @@ -305,6 +401,7 @@ async def test_integration_v2_to_v2(self): cast(ReceiveSession, ReceiveSession.INITIALIZED(session)), recv_persister, ohttp_relay, + mode, ) self.assertIsNotNone(payjoin_proposal) self.assertEqual( @@ -385,6 +482,12 @@ async def test_integration_v2_to_v2(self): print("Caught:", e) raise + async def test_integration_v2_to_v2_callback(self): + await self._run_integration_v2_to_v2("callback") + + async def test_integration_v2_to_v2_nonblocking(self): + await self._run_integration_v2_to_v2("nonblocking") + def build_sweep_psbt(sender: RpcClient, pj_uri: PjUri) -> str: outputs = {} diff --git a/payjoin-ffi/src/receive/mod.rs b/payjoin-ffi/src/receive/mod.rs index a0e81f90f..6f9a2de87 100644 --- a/payjoin-ffi/src/receive/mod.rs +++ b/payjoin-ffi/src/receive/mod.rs @@ -421,9 +421,6 @@ impl InitialReceiveTransition { } } -#[derive(Clone, Debug, uniffi::Object)] -pub struct ReceiverBuilder(payjoin::receive::v2::ReceiverBuilder); - /// Primitive representation of a transaction output for the FFI boundary. #[derive(Clone, Debug, serde::Serialize, serde::Deserialize, uniffi::Record)] pub struct TxOut { @@ -471,7 +468,7 @@ impl TxIn { } /// Primitive representation of an outpoint for the FFI boundary. -#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, uniffi::Record)] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize, uniffi::Record)] pub struct OutPoint { /// Hex-encoded txid (big-endian). pub txid: String, @@ -535,6 +532,9 @@ impl From for Weight { fn from(value: payjoin::bitcoin::Weight) -> Self { Weight { weight_units: value.to_wu() } } } +#[derive(Clone, Debug, uniffi::Object)] +pub struct ReceiverBuilder(payjoin::receive::v2::ReceiverBuilder); + #[uniffi::export] impl ReceiverBuilder { /// Creates a new [`Initialized`] with the provided parameters. @@ -815,6 +815,37 @@ impl UncheckedOriginalPayload { ))))) } + /// Extract the transaction from the Original PSBT for external broadcast suitability checks. + /// + /// Submit the result of the check to [`UncheckedOriginalPayload::apply_broadcast_suitability`]. + /// + /// Returns the consensus-encoded raw transaction bytes. + pub fn extract_tx_to_check_broadcast_suitability(&self) -> Vec { + payjoin::bitcoin::consensus::encode::serialize( + &self.0.clone().extract_tx_to_check_broadcast_suitability(), + ) + } + + /// Apply the result of an external broadcast suitability check, ensuring + /// the Original PSBT can be used as a fallback if the payjoin does + /// not complete. + /// + /// Use [`UncheckedOriginalPayload::extract_tx_to_check_broadcast_suitability`] to obtain + /// the transaction that needs to be checked. + /// + /// Returns an [`UncheckedOriginalPayloadTransition`] that, once persisted, + /// yields a [`MaybeInputsOwned`] to continue validation. + pub fn apply_broadcast_suitability( + &self, + min_fee_rate_sat_per_kwu: Option, + is_broadcast_suitable: bool, + ) -> Result { + let min_fee_rate = validate_fee_rate_sat_per_kwu_opt(min_fee_rate_sat_per_kwu)?; + Ok(UncheckedOriginalPayloadTransition(Arc::new(RwLock::new(Some( + self.0.clone().apply_broadcast_suitability(min_fee_rate, is_broadcast_suitable), + ))))) + } + /// Call this method if the only way to initiate a Payjoin with this receiver /// requires manual intervention, as in most consumer wallets. /// @@ -827,6 +858,65 @@ impl UncheckedOriginalPayload { } } +trait FfiMarkedChecklistItem { + fn result(&self) -> bool; + fn value(&self) -> V; +} + +fn to_marked_checklist( + checklist: impl Iterator>, + ffi_marked_checklist: Vec>, +) -> Result< + impl Iterator>, + payjoin::error::ImplementationError, +> +where + K: payjoin::receive::ChecklistKind, + R: FfiMarkedChecklistItem, + Vffi: From + PartialEq, +{ + payjoin::receive::mark_checklist(checklist, &mut move |item: &K::Value| { + let found_result = ffi_marked_checklist.iter().find_map(|marked_item| { + if Vffi::from(item.clone()) == marked_item.value() { + Some(marked_item.result()) + } else { + None + } + }); + match found_result { + Some(result) => Ok(result), + None => { + let msg = format!("Checklist item {item:?} has not been marked with a result"); + Err(payjoin::ImplementationError::from(msg.as_str())) + } + } + }) +} + +#[derive(Debug, uniffi::Object)] +pub struct InputOwnedChecklistItem( + payjoin::receive::ChecklistItem, +); + +#[uniffi::export] +impl InputOwnedChecklistItem { + pub fn value(&self) -> OutPoint { (*self.0.value()).into() } + pub fn mark(&self, result: bool) -> Arc { + Arc::new(MarkedInputOwnedChecklistItem { value: self.value(), result }) + } +} + +#[derive(Debug, Clone, uniffi::Object)] +pub struct MarkedInputOwnedChecklistItem { + value: OutPoint, + result: bool, +} + +impl FfiMarkedChecklistItem for MarkedInputOwnedChecklistItem { + fn result(&self) -> bool { self.result } + fn value(&self) -> OutPoint { self.value.clone() } +} + #[derive(Clone, uniffi::Object)] pub struct MaybeInputsOwned(payjoin::receive::v2::Receiver); @@ -900,6 +990,62 @@ impl MaybeInputsOwned { }), )))) } + + /// Get the inputs owned checklist for external ownership verification. + /// + /// Each item can be marked with the result via [`InputOwnedChecklistItem::mark`] + /// and passed to [`MaybeInputsOwned::apply_inputs_owned_checklist`]. + pub fn inputs_owned_checklist(&self) -> Vec> { + self.0 + .clone() + .inputs_owned_checklist() + .map(|item| Arc::new(InputOwnedChecklistItem(item))) + .collect() + } + + /// Apply the results of the input ownership checklist, ensuring none of the + /// inputs are owned by the receiver. This prevents an attacker from spending + /// the receiver's own inputs. + /// + /// Use [`MaybeInputsOwned::inputs_owned_checklist`] to obtain the items that need to be checked. + /// + /// Returns a [`MaybeInputsOwnedTransition`] that, once persisted, + /// yields a [`MaybeInputsSeen`] to continue validation. + pub fn apply_inputs_owned_checklist( + &self, + marked_checklist: Vec>, + ) -> Result { + let checklist = self.0.clone().inputs_owned_checklist(); + let marked_checklist = to_marked_checklist(checklist, marked_checklist) + .map_err(|e| ReceiverError::Implementation(Arc::new(ImplementationError::from(e))))?; + Ok(MaybeInputsOwnedTransition(Arc::new(RwLock::new(Some( + self.0.clone().apply_inputs_owned_checklist(marked_checklist), + ))))) + } +} + +#[derive(Debug, uniffi::Object)] +pub struct InputSeenChecklistItem( + payjoin::receive::ChecklistItem, +); + +#[uniffi::export] +impl InputSeenChecklistItem { + pub fn value(&self) -> OutPoint { (*self.0.value()).into() } + pub fn mark(&self, result: bool) -> Arc { + Arc::new(MarkedInputSeenChecklistItem { value: self.value(), result }) + } +} + +#[derive(Debug, Clone, uniffi::Object)] +pub struct MarkedInputSeenChecklistItem { + value: OutPoint, + result: bool, +} + +impl FfiMarkedChecklistItem for MarkedInputSeenChecklistItem { + fn result(&self) -> bool { self.result } + fn value(&self) -> OutPoint { self.value.clone() } } #[derive(Clone, uniffi::Object)] @@ -958,6 +1104,62 @@ impl MaybeInputsSeen { }), )))) } + + /// Get the inputs seen checklist for external outpoint seen verification. + /// + /// Each item can be marked with the result via [`InputSeenChecklistItem::mark`] + /// and passed to [`MaybeInputsSeen::apply_inputs_seen_checklist`]. + pub fn inputs_seen_checklist(&self) -> Vec> { + self.0 + .clone() + .inputs_seen_checklist() + .map(|item| Arc::new(InputSeenChecklistItem(item))) + .collect::>() + } + + /// Apply the results of the outpoint seen checklist, ensuring none of + /// the inputs have been seen before. This prevents input probing and replay + /// attacks (where inputs have been used in a previous payjoin attempt). + /// + /// Use [`MaybeInputsSeen::inputs_seen_checklist`] to obtain the items that need to be checked. + /// + /// Returns a [`MaybeInputsSeenTransition`] that, once persisted, + /// yields an [`OutputsUnknown`] to continue validation. + pub fn apply_inputs_seen_checklist( + &self, + marked_checklist: Vec>, + ) -> Result { + let checklist = self.0.clone().inputs_seen_checklist(); + let marked_checklist = to_marked_checklist(checklist, marked_checklist) + .map_err(|e| ReceiverError::Implementation(Arc::new(ImplementationError::from(e))))?; + Ok(MaybeInputsSeenTransition(Arc::new(RwLock::new(Some( + self.0.clone().apply_inputs_seen_checklist(marked_checklist), + ))))) + } +} + +#[derive(Debug, uniffi::Object)] +pub struct OutputOwnedChecklistItem( + payjoin::receive::ChecklistItem, +); + +#[uniffi::export] +impl OutputOwnedChecklistItem { + pub fn value(&self) -> Vec { self.0.value().to_bytes() } + pub fn mark(&self, result: bool) -> Arc { + Arc::new(MarkedOutputOwnedChecklistItem { value: self.value(), result }) + } +} + +#[derive(Debug, Clone, uniffi::Object)] +pub struct MarkedOutputOwnedChecklistItem { + value: Vec, + result: bool, +} + +impl FfiMarkedChecklistItem> for MarkedOutputOwnedChecklistItem { + fn result(&self) -> bool { self.result } + fn value(&self) -> Vec { self.value.clone() } } /// The receiver has not yet identified which outputs belong to the receiver. @@ -1012,6 +1214,38 @@ impl OutputsUnknown { }), )))) } + + /// Get the outputs owned checklist for external ownership verification. + /// + /// Each item can be marked with the result via [`OutputOwnedChecklistItem::mark`] + /// and passed to [`OutputsUnknown::apply_outputs_owned_checklist`]. + pub fn outputs_owned_checklist(&self) -> Vec> { + self.0 + .clone() + .outputs_owned_checklist() + .map(|item| Arc::new(OutputOwnedChecklistItem(item))) + .collect::>() + } + + /// Apply the results of the output ownership checklist, identifying which + /// outputs in the original transaction belong to the receiver and ensuring + /// at least one output pays the receiver. + /// + /// Use [`OutputsUnknown::outputs_owned_checklist`] to obtain the items that need to be checked. + /// + /// Returns an [`OutputsUnknownTransition`] that, once persisted, + /// yields a [`WantsOutputs`] to continue the proposal. + pub fn apply_outputs_owned_checklist( + &self, + marked_checklist: Vec>, + ) -> Result { + let checklist = self.0.clone().outputs_owned_checklist(); + let marked_checklist = to_marked_checklist(checklist, marked_checklist) + .map_err(|e| ReceiverError::Implementation(Arc::new(ImplementationError::from(e))))?; + Ok(OutputsUnknownTransition(Arc::new(RwLock::new(Some( + self.0.clone().apply_outputs_owned_checklist(marked_checklist), + ))))) + } } #[derive(uniffi::Object)] @@ -1326,7 +1560,26 @@ impl ProvisionalProposal { } /// Extract the PSBT that needs to be signed by the receiver's wallet. + /// + /// Submit the signed PSBT to [`ProvisionalProposal::finalize_signed_proposal`]. pub fn psbt_to_sign(&self) -> String { self.0.clone().psbt_to_sign().to_string() } + + /// Finalize the proposal with a signed PSBT. + /// + /// Use [`ProvisionalProposal::psbt_to_sign`] to obtain the unsigned PSBT for the receiver + /// to sign and return here. + /// + /// Returns a [`ProvisionalProposalTransition`] that, once persisted, + /// yields the final [`PayjoinProposal`]. + pub fn finalize_signed_proposal( + &self, + signed_psbt: String, + ) -> Result { + let signed_psbt = Psbt::from_str(&signed_psbt).map_err(ImplementationError::new)?; + Ok(ProvisionalProposalTransition(Arc::new(RwLock::new(Some( + self.0.clone().finalize_signed_proposal(&signed_psbt), + ))))) + } } #[derive(Clone, uniffi::Object)] @@ -1588,11 +1841,12 @@ fn try_deserialize_tx( #[uniffi::export] impl Monitor { - /// Check the network for the payjoin or fallback transaction via the - /// `find_transaction` callback. + /// Check the network for the payjoin or fallback transaction via the `find_transaction` + /// callback. /// - /// Returns a [`MonitorTransition`] that, once persisted, completes - /// the session if a transaction is found. + /// Returns a [`MonitorTransition`] that, once successfully persisted, either + /// concludes the session if a transaction is found, or yields a [`Monitor`] to + /// remain in stasis if no transaction is found yet. pub fn check_for_transaction( &self, find_transaction: Arc, @@ -1606,6 +1860,65 @@ impl Monitor { }, ))))) } + + /// Extract the txid of the fallback transaction to look it up on the network. + /// + /// Call [`Monitor::fallback_tx_exists`] to conclude the session if the + /// fallback has been found on the network. + /// + /// Returns the fallback transaction's txid. + pub fn extract_fallback_txid(&self) -> String { + self.0.clone().extract_fallback_txid().to_string() + } + + /// Extract the txid of the Payjoin proposal transaction to look it up on the network. + /// + /// Pass the Payjoin proposal transaction to [`Monitor::payjoin_tx_exists`] to + /// conclude the session. + /// + /// Returns the Payjoin proposal transaction's txid. + pub fn extract_payjoin_proposal_txid(&self) -> String { + self.0.clone().extract_payjoin_proposal_txid().to_string() + } + + /// Conclude the session when the Payjoin proposal's txid cannot be tracked. + /// + /// When the sender spends non-SegWit inputs the proposal txid changes once they sign, + /// so its broadcast cannot be monitored; see + /// [`Monitor::proposal_txid_is_stable`]. + /// + /// Returns a [`MonitorTransition`] that, once successfully persisted, closes + /// the session with `SessionOutcome::PayjoinProposalSent`. + pub fn payjoin_sent(&self) -> MonitorTransition { + MonitorTransition(Arc::new(RwLock::new(Some(self.0.clone().payjoin_sent())))) + } + + /// Conclude the session when the fallback transaction has been found on the network. + /// + /// Use [`Monitor::extract_fallback_txid`] to obtain the txid to look up. + /// + /// Returns a [`MonitorTransition`] that, once successfully persisted, closes + /// the session with `SessionOutcome::FallbackBroadcasted`. + pub fn fallback_tx_exists(&self) -> MonitorTransition { + MonitorTransition(Arc::new(RwLock::new(Some(self.0.clone().fallback_tx_exists())))) + } + + /// Conclude the session when the Payjoin proposal transaction has been found on the + /// network. + /// + /// Use [`Monitor::extract_payjoin_proposal_txid`] to obtain the txid to look + /// up, then pass the found transaction here. If its txid does not match the proposal, a + /// transient error is returned so the caller can retry. + /// + /// Returns a [`MonitorTransition`] that, once successfully persisted, closes + /// the session with `SessionOutcome::Success`. + pub fn payjoin_tx_exists( + &self, + payjoin_tx: Vec, + ) -> Result { + let tx = try_deserialize_tx(payjoin_tx)?; + Ok(MonitorTransition(Arc::new(RwLock::new(Some(self.0.clone().payjoin_tx_exists(tx)))))) + } } macro_rules! impl_proposal_txid_is_stable { From 0e341eff0451e3e15548be0d0778d1f3b8993a12 Mon Sep 17 00:00:00 2001 From: xstoicunicornx Date: Wed, 20 May 2026 13:49:55 -0500 Subject: [PATCH 3/3] Update payjoin-cli to non-blocking receive interface Migrate both v1 and v2 receiver flows in payjoin-cli from the callback-based validation API to the two-phase non-blocking API. --- payjoin-cli/src/app/v1.rs | 34 ++++++----- payjoin-cli/src/app/v2/mod.rs | 110 ++++++++++++++++------------------ 2 files changed, 72 insertions(+), 72 deletions(-) diff --git a/payjoin-cli/src/app/v1.rs b/payjoin-cli/src/app/v1.rs index f935a02a8..adb9dcb2c 100644 --- a/payjoin-cli/src/app/v1.rs +++ b/payjoin-cli/src/app/v1.rs @@ -13,7 +13,7 @@ use hyper_util::rt::TokioIo; use payjoin::bitcoin::consensus::encode::serialize_hex; use payjoin::bitcoin::{Amount, FeeRate}; use payjoin::receive::v1::{PayjoinProposal, UncheckedOriginalPayload}; -use payjoin::receive::Error; +use payjoin::receive::{mark_checklist, Error}; use payjoin::send::v1::SenderBuilder; use payjoin::{ImplementationError, IntoUrl, Uri}; use tokio::net::TcpListener; @@ -348,35 +348,40 @@ impl App { let wallet = self.wallet(); // Receive Check 1: Can Broadcast - let proposal = proposal.check_broadcast_suitability(None, |tx| { - wallet - .can_broadcast(tx) - .map_err(|e| ImplementationError::from(e.into_boxed_dyn_error())) - })?; + let is_broadcast_suitable = wallet + .can_broadcast(&proposal.extract_tx_to_check_broadcast_suitability()) + .map_err(|e| ImplementationError::from(e.into_boxed_dyn_error()))?; + let proposal = proposal.apply_broadcast_suitability(None, is_broadcast_suitable)?; tracing::trace!("check1"); // 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 proposal = proposal.check_inputs_not_owned(&mut |outpoint| { + let checklist = proposal.inputs_owned_checklist(); + let marked_checklist = mark_checklist(checklist, &mut |outpoint| { wallet .is_my_outpoint(outpoint) .map_err(|e| ImplementationError::from(e.into_boxed_dyn_error())) })?; + let proposal = proposal.apply_inputs_owned_checklist(marked_checklist)?; tracing::trace!("check2"); // Receive Check 3: have we seen this input before? More of a check for non-interactive i.e. payment processor receivers. - let payjoin = proposal.check_no_inputs_seen_before(&mut |input| { + let checklist = proposal.inputs_seen_checklist(); + let marked_checklist = mark_checklist(checklist, &mut |input| { Ok(self.db.insert_input_seen_before(*input)?) })?; + let payjoin = proposal.apply_inputs_seen_checklist(marked_checklist)?; tracing::trace!("check3"); - let payjoin = payjoin.identify_receiver_outputs(&mut |output_script| { + let checklist = payjoin.outputs_owned_checklist(); + let marked_checklist = mark_checklist(checklist, &mut |output_script| { wallet .is_mine(output_script) .map_err(|e| ImplementationError::from(e.into_boxed_dyn_error())) })?; + let payjoin = payjoin.apply_outputs_owned_checklist(marked_checklist)?; let payjoin = payjoin .substitute_receiver_script( @@ -396,11 +401,12 @@ impl App { let provisional_payjoin = wants_fee_range.apply_fee_range(None, self.config.max_fee_rate)?; - let payjoin_proposal = provisional_payjoin.finalize_proposal(|psbt| { - self.wallet - .process_psbt(psbt) - .map_err(|e| ImplementationError::from(e.into_boxed_dyn_error())) - })?; + let psbt = provisional_payjoin.psbt_to_sign(); + let signed_psbt = self + .wallet + .process_psbt(&psbt) + .map_err(|e| ImplementationError::from(e.into_boxed_dyn_error()))?; + let payjoin_proposal = provisional_payjoin.finalize_signed_proposal(&signed_psbt)?; Ok(payjoin_proposal) } } diff --git a/payjoin-cli/src/app/v2/mod.rs b/payjoin-cli/src/app/v2/mod.rs index cd2f42924..5bab087e1 100644 --- a/payjoin-cli/src/app/v2/mod.rs +++ b/payjoin-cli/src/app/v2/mod.rs @@ -5,6 +5,7 @@ use anyhow::{anyhow, Context, Result}; use payjoin::bitcoin::consensus::encode::serialize_hex; use payjoin::bitcoin::{Amount, FeeRate, Transaction}; use payjoin::persist::{OptionalTransitionOutcome, SessionPersister}; +use payjoin::receive::mark_checklist; use payjoin::receive::v2::{ replay_event_log as replay_receiver_event_log, HasReplyableError, Initialized, MaybeInputsOwned, MaybeInputsSeen, Monitor, OutputsUnknown, PayjoinProposal, @@ -1021,13 +1022,11 @@ impl App { persister: &ReceiverPersister, ) -> Result { let wallet = self.wallet(); - let proposal = proposal - .check_broadcast_suitability(None, |tx| { - wallet - .can_broadcast(tx) - .map_err(|e| ImplementationError::from(e.into_boxed_dyn_error())) - }) - .save(persister)?; + let is_broadcast_suitable = wallet + .can_broadcast(&proposal.extract_tx_to_check_broadcast_suitability()) + .map_err(|e| ImplementationError::from(e.into_boxed_dyn_error()))?; + let proposal = + proposal.apply_broadcast_suitability(None, is_broadcast_suitable).save(persister)?; persister.print( "Fallback transaction received. Consider broadcasting this to get paid if the Payjoin fails:", @@ -1042,13 +1041,13 @@ impl App { persister: &ReceiverPersister, ) -> Result { let wallet = self.wallet(); - let proposal = proposal - .check_inputs_not_owned(&mut |outpoint| { - wallet - .is_my_outpoint(outpoint) - .map_err(|e| ImplementationError::from(e.into_boxed_dyn_error())) - }) - .save(persister)?; + let checklist = proposal.inputs_owned_checklist(); + let marked_checklist = mark_checklist(checklist, &mut |outpoint| { + wallet + .is_my_outpoint(outpoint) + .map_err(|e| ImplementationError::from(e.into_boxed_dyn_error())) + })?; + let proposal = proposal.apply_inputs_owned_checklist(marked_checklist).save(persister)?; Ok(ReceiveSession::MaybeInputsSeen(proposal)) } @@ -1057,11 +1056,11 @@ impl App { proposal: Receiver, persister: &ReceiverPersister, ) -> Result { - let proposal = proposal - .check_no_inputs_seen_before(&mut |input| { - Ok(self.db.insert_input_seen_before(*input)?) - }) - .save(persister)?; + let checklist = proposal.inputs_seen_checklist(); + let marked_checklist = mark_checklist(checklist, &mut |input| { + Ok(self.db.insert_input_seen_before(*input)?) + })?; + let proposal = proposal.apply_inputs_seen_checklist(marked_checklist).save(persister)?; Ok(ReceiveSession::OutputsUnknown(proposal)) } @@ -1071,13 +1070,13 @@ impl App { persister: &ReceiverPersister, ) -> Result { let wallet = self.wallet(); - let proposal = proposal - .identify_receiver_outputs(&mut |output_script| { - wallet - .is_mine(output_script) - .map_err(|e| ImplementationError::from(e.into_boxed_dyn_error())) - }) - .save(persister)?; + let checklist = proposal.outputs_owned_checklist(); + let marked_checklist = mark_checklist(checklist, &mut |output_script| { + wallet + .is_mine(output_script) + .map_err(|e| ImplementationError::from(e.into_boxed_dyn_error())) + })?; + let proposal = proposal.apply_outputs_owned_checklist(marked_checklist).save(persister)?; Ok(ReceiveSession::WantsOutputs(proposal)) } @@ -1126,13 +1125,11 @@ impl App { persister: &ReceiverPersister, ) -> Result { let wallet = self.wallet(); - let proposal = proposal - .finalize_proposal(|psbt| { - wallet - .process_psbt(psbt) - .map_err(|e| ImplementationError::from(e.into_boxed_dyn_error())) - }) - .save(persister)?; + let psbt = proposal.psbt_to_sign(); + let signed_psbt = wallet + .process_psbt(&psbt) + .map_err(|e| ImplementationError::from(e.into_boxed_dyn_error()))?; + let proposal = proposal.finalize_signed_proposal(&signed_psbt).save(persister)?; Ok(ReceiveSession::PayjoinProposal(proposal)) } @@ -1173,7 +1170,7 @@ impl App { /// session's perspective, so it stays local rather than in the driver. async fn monitor_payjoin_proposal( &self, - mut proposal: Receiver, + proposal: Receiver, persister: &ReceiverPersister, ) -> Result<()> { // On a session resumption, the receiver will resume again in this state. @@ -1185,33 +1182,30 @@ impl App { tracing::debug!("Polling for payment confirmation"); + let fallback_txid = proposal.extract_fallback_txid(); + let payjoin_txid = proposal.extract_payjoin_proposal_txid(); + let get_raw_tx = |txid| { + self.wallet() + .get_raw_transaction(&txid) + .map_err(|e| ImplementationError::from(e.into_boxed_dyn_error())) + }; + if !proposal.proposal_txid_is_stable() { + proposal.payjoin_sent().save(persister)?; + println!("Unable to monitor for fallback tx containing non-segwit inputs, completing session"); + return Ok(()); + } let result = tokio::time::timeout(timeout_duration, async { loop { interval.tick().await; - let check_result = proposal - .check_for_transaction(|txid| { - self.wallet() - .get_raw_transaction(&txid) - .map_err(|e| ImplementationError::from(e.into_boxed_dyn_error())) - }) - .save(persister); - - match check_result { - Ok(OptionalTransitionOutcome::Progress(())) => { - persister.print("Payjoin transaction detected in the mempool!"); - return Ok(()); - } - Ok(OptionalTransitionOutcome::Stasis(current_state)) => { - proposal = current_state; - } - Err(e) if e.is_transient() => { - tracing::debug!( - "Transient error checking for transaction, retrying: {e:?}" - ); - proposal = - e.transient_state().expect("transient error carries current state"); - } - Err(e) => return Err(e.into()), + if let Some(tx) = get_raw_tx(payjoin_txid)? { + proposal.payjoin_tx_exists(tx).save(persister)?; + println!("Payjoin transaction detected in the mempool!"); + return Ok(()); + }; + if get_raw_tx(fallback_txid)?.is_some() { + proposal.fallback_tx_exists().save(persister)?; + println!("Fallback transaction detected in the mempool!"); + return Ok(()); } } })