From 9591f9a81999bd4d50fc9385df232baf15dd68c1 Mon Sep 17 00:00:00 2001 From: xstoicunicornx Date: Wed, 13 May 2026 00:28:42 -0500 Subject: [PATCH 1/4] 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 spacebear21@users.noreply.github.com --- payjoin/src/core/receive/error.rs | 5 + payjoin/src/core/receive/mod.rs | 309 +++++++++++++++++++++----- payjoin/src/core/receive/v1/mod.rs | 167 +++++++++++++- payjoin/src/core/receive/v2/mod.rs | 346 ++++++++++++++++++++++++----- 4 files changed, 712 insertions(+), 115 deletions(-) diff --git a/payjoin/src/core/receive/error.rs b/payjoin/src/core/receive/error.rs index 8e0d31e8a..93bfca4e5 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 0354d3ea6..d3be7a017 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,119 @@ 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 = ScriptBuf; +} + +/// 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("Empty checklist"))?.final_index(); + + if items.len() != final_index + 1 { + return Err(ImplementationError::from("Incomplete checklist")); + } + for (current_index, item) in items.iter().enumerate() { + if item.index() != current_index { + let msg = format!("Missing checklist item at index {current_index}"); + return Err(ImplementationError::from(msg.as_str())); + } + if item.final_index() != final_index { + return Err(ImplementationError::from( + "Checklist has inconsistent expected number of items", + )); + } + } + Ok(items.into_iter()) +} + /// Validate the payload of a Payjoin request for PSBT and Params sanity pub(crate) fn parse_payload( base64: &str, @@ -257,7 +371,7 @@ pub struct PsbtContext { impl PsbtContext { /// Prepare the PSBT by creating a new PSBT and copying only the fields allowed by the [spec](https://github.com/bitcoin/bips/blob/master/bip-0078.mediawiki#senders-payjoin-proposal-checklist) - 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 @@ -338,6 +452,15 @@ impl PsbtContext { ) -> Result { let psbt = self.psbt_to_sign(); let signed_psbt = wallet_process_psbt(&psbt)?; + self.finalize_signed_proposal(signed_psbt) + } + + /// Finalizes the signed payjoin proposal PSBT which the sender will find acceptable before + /// they sign the transaction and broadcast it to the network. + /// + /// 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 { @@ -369,6 +492,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, + can_broadcast: bool, ) -> Result<(), Error> { let original_psbt_fee_rate = self.psbt_fee_rate()?; if let Some(min_fee_rate) = min_fee_rate { @@ -380,9 +514,7 @@ impl OriginalPayload { .into()); } } - if can_broadcast(&self.psbt.clone().extract_tx_unchecked_fee_rate()) - .map_err(Error::Implementation)? - { + if can_broadcast { Ok(()) } else { Err(InternalPayloadError::OriginalPsbtNotBroadcastable.into()) @@ -396,64 +528,122 @@ impl OriginalPayload { &self, is_owned: &mut impl FnMut(&Script) -> Result, ) -> Result<(), Error> { - let mut err: Result<(), Error> = Ok(()); - if let Some(e) = self + let marked_checklist = + mark_checklist(self.inputs_owned_checklist()?, &mut |script: &ScriptBuf| { + is_owned(script.as_script()) + })?; + self.apply_inputs_owned_checklist(marked_checklist) + } + + pub fn inputs_owned_checklist( + &self, + ) -> Result>, Error> { + let final_index = self.psbt.input_pairs().count() - 1; + let checklist = self .psbt .input_pairs() - .scan(&mut err, |err, input| match input.previous_txout() { - Ok(txout) => Some(txout.script_pubkey.to_owned()), - Err(e) => { - **err = Err(InternalPayloadError::PrevTxOut(e).into()); - None - } - }) - .find_map(|script| match is_owned(&script) { - Ok(false) => None, - Ok(true) => Some(InternalPayloadError::InputOwned(script).into()), - Err(e) => Some(Error::Implementation(e)), + .enumerate() + .map(|(index, input)| match input.previous_txout() { + Ok(txout) => Ok(ChecklistItem::::new( + txout.script_pubkey.to_owned(), + index, + final_index, + )), + Err(e) => Err(InternalPayloadError::PrevTxOut(e)), }) - { - return Err(e); + .collect::>, InternalPayloadError>>()?; + Ok(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().clone()).into()), + None => Ok(()), } - err?; - 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()); } @@ -516,6 +706,28 @@ pub(crate) mod tests { } } + #[test] + fn checklist_item_mark_preserves_value_and_index() { + let script = ScriptBuf::new_p2pkh(&PubkeyHash::from_byte_array(DUMMY20)); + let item = ChecklistItem::::new(script.clone(), 2, 4); + + // The unmarked item exposes its value. + assert_eq!(item.value(), &script); + + // Marking consumes the item and carries value and index through, alongside the result. + let marked = item.mark(true); + assert_eq!(marked.value(), &script); + assert_eq!(marked.index(), 2); + assert!(marked.result()); + + // A false result is recorded faithfully. + let item = ChecklistItem::::new(script.clone(), 0, 0); + let marked = item.mark(false); + assert!(!marked.result()); + assert_eq!(marked.value(), &script); + assert_eq!(marked.index(), 0); + } + #[test] fn input_pair_with_expected_weight() { let p2wsh_txout = TxOut { @@ -1034,29 +1246,20 @@ pub(crate) mod tests { #[test] fn test_finalize_proposal() { - 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 + // Outcome 1: 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"); } } diff --git a/payjoin/src/core/receive/v1/mod.rs b/payjoin/src/core/receive/v1/mod.rs index 1102728a1..1addd63c2 100644 --- a/payjoin/src/core/receive/v1/mod.rs +++ b/payjoin/src/core/receive/v1/mod.rs @@ -109,7 +109,43 @@ impl UncheckedOriginalPayload { min_fee_rate: Option, can_broadcast: impl Fn(&bitcoin::Transaction) -> Result, ) -> Result { - self.original.check_broadcast_suitability(min_fee_rate, can_broadcast)?; + let tx = self.extract_tx_to_check_broadcast_suitability(); + self.apply_broadcast_suitability(min_fee_rate, can_broadcast(&tx)?) + } + + /// Extracts the original PSBT so caller can check that the proposal can be broadcasted. + /// + /// Result of the broadcastibility check should then be returned to + /// [`Self::apply_broadcast_suitability`]. + /// + /// If the receiver is a non-interactive payment processor (ex. a donation page which generates + /// a new QR code for each visit), then it should make sure that the original PSBT is broadcastable + /// as a fallback mechanism in case the payjoin fails. This validation would be equivalent to + /// `testmempoolaccept` Bitcoin Core RPC call returning `{"allowed": true,...}`. + pub fn extract_tx_to_check_broadcast_suitability(&self) -> bitcoin::Transaction { + self.original.psbt.clone().extract_tx_unchecked_fee_rate() + } + + /// Processes the result of whether the original PSBT in the proposal can be broadcasted. + /// + /// Call [`Self::extract_tx_to_check_broadcast_suitability`] first to acquire the tx + /// to be checked for broadcastibility. + /// + /// If the receiver is a non-interactive payment processor (ex. a donation page which generates + /// a new QR code for each visit), then it should make sure that the original PSBT is broadcastable + /// as a fallback mechanism in case the payjoin fails. This validation would be equivalent to + /// `testmempoolaccept` Bitcoin Core RPC call returning `{"allowed": true,...}`. + /// + /// Receiver can optionally set a minimum fee rate which will be enforced on the original PSBT in the proposal. + /// This can be used to further prevent probing attacks since the attacker would now need to probe the receiver + /// with transactions which are both broadcastable and pay high fee. Unrelated to the probing attack scenario, + /// this parameter also makes operating in a high fee environment easier for the receiver. + pub fn 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 }) } @@ -151,7 +187,36 @@ impl MaybeInputsOwned { self, is_owned: &mut impl FnMut(&Script) -> Result, ) -> Result { - self.original.check_inputs_not_owned(is_owned)?; + let marked_checklist = + mark_checklist(self.inputs_owned_checklist()?, &mut |script: &ScriptBuf| { + is_owned(script.as_script()) + })?; + self.apply_inputs_owned_checklist(marked_checklist) + } + + /// Get [`ChecklistItem`]s that hold the input scripts that need to be checked for ownership by the + /// receiver. + /// + /// 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`]. + /// + /// An attacker can try to spend the receiver's own inputs. This check prevents that. + pub fn inputs_owned_checklist( + &self, + ) -> Result>, Error> { + self.original.inputs_owned_checklist() + } + + /// Applies the input ownership checklist results to advance the state machine. + /// + /// Use [`Self::inputs_owned_checklist`] to obtain the items that need to be checked. + /// + /// An attacker can try to spend the receiver's own inputs. This check prevents that. + 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 }) } } @@ -176,7 +241,41 @@ 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 [`ChecklistItem`]s that hold the input outpoints that need to be checked for whether they + /// have already been seen by the receiver. + /// + /// 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`]. + /// + /// This check prevents the following attacks: + /// 1. Probing attacks, where the sender can use the exact same proposal (or with minimal change) + /// to have the receiver reveal their UTXO set by contributing to all proposals with different inputs + /// and sending them back to the receiver. + /// 2. Re-entrant payjoin, where the sender uses the payjoin PSBT of a previous payjoin as the + /// original proposal PSBT of the current, new payjoin. + pub fn inputs_seen_checklist(&self) -> impl Iterator> { + self.original.inputs_seen_checklist() + } + + /// Applies the input seen checklist results to advance the state machine. + /// + /// Use [`Self::inputs_seen_checklist`] to obtain the items that need to be checked. + /// + /// This check prevents the following attacks: + /// 1. Probing attacks, where the sender can use the exact same proposal (or with minimal change) + /// to have the receiver reveal their UTXO set by contributing to all proposals with different inputs + /// and sending them back to the receiver. + /// 2. Re-entrant payjoin, where the sender uses the payjoin PSBT of a previous payjoin as the + /// original proposal PSBT of the current, new payjoin. + pub fn apply_inputs_seen_checklist( + self, + marked_checklist: impl IntoIterator>, + ) -> Result { + self.original.apply_inputs_seen_checklist(marked_checklist)?; Ok(OutputsUnknown { original: self.original }) } } @@ -208,7 +307,48 @@ 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 [`ChecklistItem`]s that hold the output scripts that need to be checked for ownership + /// by 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`]. + /// + /// Additionally, this function also protects the receiver from accidentally subtracting fees + /// from their own outputs: when a sender is sending a proposal, + /// they can select an output which they want the receiver to subtract fees from to account for + /// the increased transaction size. If a sender specifies a receiver output for this purpose, this + /// function sets that parameter to None so that it is ignored in subsequent steps of the + /// receiver flow. This protects the receiver from accidentally subtracting fees from their own + /// outputs. + #[cfg_attr(not(feature = "v1"), allow(dead_code))] + pub fn outputs_owned_checklist(&self) -> impl Iterator> { + self.original.outputs_owned_checklist() + } + + /// Applies the output owned checklist results to advance the state machine. + /// + /// Use [`Self::outputs_owned_checklist`] to obtain the items that need to be checked. + /// + /// Additionally, this function also protects the receiver from accidentally subtracting fees + /// from their own outputs: when a sender is sending a proposal, + /// they can select an output which they want the receiver to subtract fees from to account for + /// the increased transaction size. If a sender specifies a receiver output for this purpose, this + /// function sets that parameter to None so that it is ignored in subsequent steps of the + /// receiver flow. This protects the receiver from accidentally subtracting fees from their own + /// outputs. + #[cfg_attr(not(feature = "v1"), allow(dead_code))] + pub fn apply_outputs_owned_checklist( + &self, + marked_checklist: impl IntoIterator>, + ) -> Result { + self.original.apply_outputs_owned_checklist(marked_checklist) } } @@ -290,11 +430,9 @@ 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) } /// The Payjoin proposal PSBT that the receiver needs to sign @@ -303,6 +441,17 @@ impl ProvisionalProposal { /// is different from the entity that has access to the private keys, /// so the PSBT to sign must be accessible to such implementers. pub fn psbt_to_sign(&self) -> Psbt { self.psbt_context.psbt_to_sign() } + + /// Finalizes the Payjoin proposal into a PSBT which the sender will find acceptable before + /// they sign the transaction and broadcast it to the network. + /// + /// This takes a receiver signed PSBT payjoin proposal and finalizes it for broadcast to + /// the sender. Use [`Self::psbt_to_sign`] to obtain the payjoin proposal's unsigned + /// PSBT for receiver to sign and return here. + pub fn finalize_signed_proposal(self, signed_psbt: &Psbt) -> Result { + let finalized_psbt = self.psbt_context.finalize_signed_proposal(signed_psbt.clone())?; + Ok(PayjoinProposal { payjoin_psbt: finalized_psbt }) + } } /// A finalized Payjoin proposal, complete with fees and receiver signatures, that the sender diff --git a/payjoin/src/core/receive/v2/mod.rs b/payjoin/src/core/receive/v2/mod.rs index feaf596ab..cad2b2f14 100644 --- a/payjoin/src/core/receive/v2/mod.rs +++ b/payjoin/src/core/receive/v2/mod.rs @@ -30,7 +30,7 @@ use std::time::Duration; use bitcoin::hashes::{sha256, Hash}; use bitcoin::psbt::Psbt; -use bitcoin::{Address, Amount, FeeRate, OutPoint, Script, TxOut, Txid}; +use bitcoin::{Address, Amount, FeeRate, OutPoint, Script, ScriptBuf, 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}; @@ -755,7 +758,52 @@ impl Receiver { Error, Receiver, > { - match self.state.original.check_broadcast_suitability(min_fee_rate, can_broadcast) { + let tx = self.extract_tx_to_check_broadcast_suitability(); + match can_broadcast(&tx) { + Ok(is_broadcast_suitable) => + self.apply_broadcast_suitability(min_fee_rate, is_broadcast_suitable), + Err(e) => MaybeFatalTransition::transient(e.into()), + } + } + + /// Extracts the original PSBT so caller can check that the proposal can be broadcasted. + /// + /// Result of the broadcastibility check should then be returned to + /// [`Receiver::apply_broadcast_suitability`]. + /// + /// If the receiver is a non-interactive payment processor (ex. a donation page which generates + /// a new QR code for each visit), then it should make sure that the original PSBT is broadcastable + /// as a fallback mechanism in case the payjoin fails. This validation would be equivalent to + /// `testmempoolaccept` Bitcoin Core RPC call returning `{"allowed": true,...}`. + pub fn extract_tx_to_check_broadcast_suitability(&self) -> bitcoin::Transaction { + self.original.psbt.clone().extract_tx_unchecked_fee_rate() + } + + /// Processes the result of whether the original PSBT in the proposal can be broadcasted. + /// + /// Call [`Receiver::extract_tx_to_check_broadcast_suitability`] first to + /// acquire the tx to be checked for broadcastibility. + /// + /// If the receiver is a non-interactive payment processor (ex. a donation page which generates + /// a new QR code for each visit), then it should make sure that the original PSBT is broadcastable + /// as a fallback mechanism in case the payjoin fails. This validation would be equivalent to + /// `testmempoolaccept` Bitcoin Core RPC call returning `{"allowed": true,...}`. + /// + /// Receiver can optionally set a minimum fee rate which will be enforced on the original PSBT in the proposal. + /// This can be used to further prevent probing attacks since the attacker would now need to probe the receiver + /// with transactions which are both broadcastable and pay high fee. Unrelated to the probing attack scenario, + /// this parameter also makes operating in a high fee environment easier for the receiver. + pub fn apply_broadcast_suitability( + self, + min_fee_rate: Option, + is_broadcast_suitable: bool, + ) -> MaybeFatalTransition< + SessionEvent, + Receiver, + Error, + Receiver, + > { + match self.state.original.apply_broadcast_suitability(min_fee_rate, is_broadcast_suitable) { Ok(()) => MaybeFatalTransition::success( SessionEvent::CheckedBroadcastSuitability(), Receiver { @@ -835,7 +883,59 @@ impl Receiver { Error, Receiver, > { - match self.state.original.check_inputs_not_owned(is_owned) { + match self.inputs_owned_checklist() { + Ok(input_scripts) => match mark_checklist(input_scripts, &mut |script: &ScriptBuf| { + is_owned(script.as_script()) + }) { + Ok(marked_checklist) => self.apply_inputs_owned_checklist(marked_checklist), + Err(e) => MaybeFatalTransition::transient(e.into()), + }, + Err(e) => match e { + Error::Implementation(_) => MaybeFatalTransition::transient(e), + _ => MaybeFatalTransition::replyable_error( + SessionEvent::GotReplyableError((&e).into()), + Receiver { + state: HasReplyableError { + error_reply: (&e).into(), + fallback_tx: Some(self.state.fallback_tx()), + }, + session_context: self.session_context, + }, + e, + ), + }, + } + } + + /// Get [`ChecklistItem`]s that hold the input scripts that need to be checked for ownership by the + /// receiver. + /// + /// 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`]. + /// + /// An attacker can try to spend the receiver's own inputs. This check prevents that. + pub fn inputs_owned_checklist( + &self, + ) -> Result>, Error> { + self.state.original.inputs_owned_checklist() + } + + /// Applies the input ownership checklist results to advance the state machine. + /// + /// Use [`Receiver::inputs_owned_checklist`] to obtain the items that need to be checked. + /// + /// An attacker can try to spend the receiver's own inputs. This check prevents that. + pub fn apply_inputs_owned_checklist( + self, + marked_checklist: impl IntoIterator>, + ) -> MaybeFatalTransition< + SessionEvent, + Receiver, + Error, + Receiver, + > { + match self.state.original.apply_inputs_owned_checklist(marked_checklist) { Ok(()) => MaybeFatalTransition::success( SessionEvent::CheckedInputsNotOwned(), Receiver { @@ -895,7 +995,49 @@ impl Receiver { Error, Receiver, > { - 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()), + } + } + + /// Get [`ChecklistItem`]s that hold the input outpoints that need to be checked for whether they + /// have already been seen by the receiver. + /// + /// 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`]. + /// + /// This check prevents the following attacks: + /// 1. Probing attacks, where the sender can use the exact same proposal (or with minimal change) + /// to have the receiver reveal their UTXO set by contributing to all proposals with different inputs + /// and sending them back to the receiver. + /// 2. Re-entrant payjoin, where the sender uses the payjoin PSBT of a previous payjoin as the + /// original proposal PSBT of the current, new payjoin. + pub fn inputs_seen_checklist(&self) -> impl Iterator> { + self.state.original.inputs_seen_checklist() + } + + /// Applies the input seen checklist results to advance the state machine. + /// + /// Use [`Receiver::inputs_seen_checklist`] to obtain the items that need to be checked. + /// + /// This check prevents the following attacks: + /// 1. Probing attacks, where the sender can use the exact same proposal (or with minimal change) + /// to have the receiver reveal their UTXO set by contributing to all proposals with different inputs + /// and sending them back to the receiver. + /// 2. Re-entrant payjoin, where the sender uses the payjoin PSBT of a previous payjoin as the + /// original proposal PSBT of the current, new payjoin. + pub fn apply_inputs_seen_checklist( + self, + marked_checklist: impl IntoIterator>, + ) -> MaybeFatalTransition< + SessionEvent, + Receiver, + Error, + Receiver, + > { + match self.state.original.apply_inputs_seen_checklist(marked_checklist) { Ok(()) => MaybeFatalTransition::success( SessionEvent::CheckedNoInputsSeenBefore(), Receiver { @@ -959,9 +1101,56 @@ impl Receiver { Receiver, Error, Receiver, + > { + 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()), + } + } + + /// Get [`ChecklistItem`]s that hold the output scripts that need to be checked for ownership + /// by 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`]. + /// + /// Additionally, this function also protects the receiver from accidentally subtracting fees + /// from their own outputs: when a sender is sending a proposal, + /// they can select an output which they want the receiver to subtract fees from to account for + /// the increased transaction size. If a sender specifies a receiver output for this purpose, this + /// function sets that parameter to None so that it is ignored in subsequent steps of the + /// receiver flow. This protects the receiver from accidentally subtracting fees from their own + /// outputs. + pub fn outputs_owned_checklist(&self) -> impl Iterator> { + self.state.original.outputs_owned_checklist() + } + + /// Applies the output owned checklist results to advance the state machine. + /// + /// Use [`Receiver::outputs_owned_checklist`] to obtain the items that need + /// to be checked. + /// + /// Additionally, this function also protects the receiver from accidentally subtracting fees + /// from their own outputs: when a sender is sending a proposal, + /// they can select an output which they want the receiver to subtract fees from to account for + /// the increased transaction size. If a sender specifies a receiver output for this purpose, this + /// function sets that parameter to None so that it is ignored in subsequent steps of the + /// receiver flow. This protects the receiver from accidentally subtracting fees from their own + /// outputs. + pub fn apply_outputs_owned_checklist( + self, + marked_checklist: impl IntoIterator>, + ) -> MaybeFatalTransition< + SessionEvent, + Receiver, + Error, + Receiver, > { let fallback_tx = Some(self.state.fallback_tx()); - match self.state.original.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 }, @@ -1206,19 +1395,12 @@ impl Receiver { wallet_process_psbt: impl Fn(&Psbt) -> Result, ) -> MaybeTransientTransition, ImplementationError> { - let original_psbt = self.state.psbt_context.original_psbt.clone(); - let payjoin_psbt = match self.state.psbt_context.finalize_proposal(wallet_process_psbt) { - Ok(payjoin_psbt) => payjoin_psbt, - Err(e) => { - return MaybeTransientTransition::transient(e); - } - }; - 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), - Receiver { state: payjoin_proposal, session_context: self.session_context }, - ) + 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), + } } /// The Payjoin proposal PSBT that the receiver needs to sign @@ -1228,6 +1410,33 @@ impl Receiver { /// so the PSBT to sign must be accessible to such implementers. pub fn psbt_to_sign(&self) -> Psbt { self.state.psbt_context.psbt_to_sign() } + /// Finalizes the Payjoin proposal into a PSBT which the sender will find acceptable before + /// they sign the transaction and broadcast it to the network. + /// + /// This takes a receiver signed PSBT payjoin proposal and finalizes it for broadcast to + /// the sender. Use [`Receiver::psbt_to_sign`] to obtain the payjoin + /// proposal's unsigned PSBT for receiver to sign and return here. + pub fn finalize_signed_proposal( + self, + signed_psbt: &Psbt, + ) -> MaybeTransientTransition, ImplementationError> + { + let original_psbt = self.state.psbt_context.original_psbt.clone(); + let payjoin_psbt = + match self.state.psbt_context.finalize_signed_proposal(signed_psbt.clone()) { + Ok(payjoin_psbt) => payjoin_psbt, + Err(e) => { + return MaybeTransientTransition::transient(e); + } + }; + 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), + Receiver { state: payjoin_proposal, session_context: self.session_context }, + ) + } + pub(crate) fn apply_payjoin_proposal(self, payjoin_psbt: Psbt) -> ReceiveSession { let psbt_context = PsbtContext { payjoin_psbt, @@ -1486,60 +1695,91 @@ 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 fallback_tx.input.iter().any(|txin| txin.witness.is_empty()) { - return MaybeFatalOrSuccessTransition::success(SessionEvent::Closed( - SessionOutcome::PayjoinProposalSent, - )); + if let transition @ MaybeFatalOrSuccessTransition::Success(_) = + self.check_fallback_monitorable() + { + return transition; } - 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()), - )); - } - // TODO: should we check for witness and scriptsig on the tx? - let mut sender_witnesses = vec![]; - - for i in self.state.psbt_context.sender_input_indexes() { - let input = - tx.input.get(i).expect("sender_input_indexes should return valid indices"); - sender_witnesses.push((input.script_sig.clone(), input.witness.clone())); - } - // Payjoin transaction with SegWit inputs was detected. Log the signatures and complete the session. - return MaybeFatalOrSuccessTransition::success(SessionEvent::Closed( - SessionOutcome::Success(sender_witnesses), - )); - } + 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)), } // 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)), } MaybeFatalOrSuccessTransition::no_results(self.clone()) } + + pub fn extract_fallback_txid(&self) -> Txid { self.state.fallback_tx().compute_txid() } + + pub fn extract_payjoin_proposal_txid(&self) -> Txid { + self.state.psbt_context.payjoin_psbt.clone().extract_tx_unchecked_fee_rate().compute_txid() + } + + pub fn check_fallback_monitorable( + &self, + ) -> MaybeFatalOrSuccessTransition { + if has_empty_witness(&self.state.fallback_tx()) { + return MaybeFatalOrSuccessTransition::success(SessionEvent::Closed( + SessionOutcome::PayjoinProposalSent, + )); + } + + MaybeFatalOrSuccessTransition::no_results(self.clone()) + } + + pub fn fallback_tx_exists(&self) -> MaybeFatalOrSuccessTransition { + MaybeFatalOrSuccessTransition::success(SessionEvent::Closed( + SessionOutcome::FallbackBroadcasted, + )) + } + + pub fn payjoin_tx_exists( + &self, + tx: Transaction, + ) -> MaybeFatalOrSuccessTransition { + // TODO: should we check for witness and scriptsig on the tx? + 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(), + ), + )); + } + let mut sender_witnesses = vec![]; + + for i in self.state.psbt_context.sender_input_indexes() { + let input = tx.input.get(i).expect("sender_input_indexes should return valid indices"); + sender_witnesses.push((input.script_sig.clone(), input.witness.clone())); + } + // Payjoin transaction with SegWit inputs was detected. Log the signatures and complete the session. + MaybeFatalOrSuccessTransition::success(SessionEvent::Closed(SessionOutcome::Success( + sender_witnesses, + ))) + } +} + +fn has_empty_witness(tx: &Transaction) -> bool { + tx.input.iter().any(|txin| txin.witness.is_empty()) } /// Derive a mailbox endpoint on a directory given a [`ShortId`]. From 754cbd209ffe5e130a6135f739cf70e0ed0be71c Mon Sep 17 00:00:00 2001 From: xstoicunicornx Date: Wed, 13 May 2026 19:32:26 -0500 Subject: [PATCH 2/4] 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 | 524 +++++++++++------- .../javascript/test/integration.test.ts | 128 ++++- .../test/test_payjoin_integration_test.py | 175 ++++-- payjoin-ffi/src/receive/mod.rs | 293 +++++++++- 5 files changed, 962 insertions(+), 296 deletions(-) diff --git a/payjoin-ffi/csharp/IntegrationTests.cs b/payjoin-ffi/csharp/IntegrationTests.cs index 76019585d..2bc705714 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, + } + public class IntegrationTests : IAsyncLifetime { private static string RpcCall(RpcClient rpc, string method, params string?[] args) => rpc.Call(method, args); @@ -168,6 +174,7 @@ private static InputPair[] GetInputs(RpcClient rpc) RpcClient receiverRpc, InMemoryReceiverPersister recvPersister, string ohttpRelay, + TransitionMode mode, CancellationToken cancellationToken) { var request = receiver.CreatePollRequest(ohttpRelay); @@ -192,7 +199,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"); @@ -201,88 +208,157 @@ 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); } private Task ProcessMaybeInputsOwned( MaybeInputsOwned proposal, RpcClient receiverRpc, - InMemoryReceiverPersister recvPersister) + InMemoryReceiverPersister recvPersister, + TransitionMode mode) { - using var transition = proposal.CheckInputsNotOwned(new IsScriptOwnedCallback(receiverRpc)); - using var maybeInputsSeen = transition.Save(recvPersister); + MaybeInputsSeen maybeInputsSeen; - return ProcessMaybeInputsSeen(maybeInputsSeen, receiverRpc, recvPersister); + if (mode == TransitionMode.Callback) + { + using var transition = proposal.CheckInputsNotOwned(new IsScriptOwnedCallback(receiverRpc)); + maybeInputsSeen = transition.Save(recvPersister); + } + else + { + var markedChecklist = proposal.InputsOwnedChecklist() + .Select(item => item.Mark(new IsScriptOwnedCallback(receiverRpc).Callback(item.Value()))) + .ToArray(); + using var transition = proposal.ApplyInputsOwnedChecklist(markedChecklist); + maybeInputsSeen = transition.Save(recvPersister); + } + + return ProcessMaybeInputsSeen(maybeInputsSeen, receiverRpc, recvPersister, mode); } 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); } 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); } 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); } 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); } 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); } 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); } @@ -438,8 +514,10 @@ public void TestFfiValidation() }); } - [Fact] - public async Task TestIntegrationV2ToV2() + [Theory] + [InlineData(TransitionMode.Callback)] + [InlineData(TransitionMode.Nonblocking)] + public async Task TestIntegrationV2ToV2(TransitionMode mode) { var cancellationToken = TestContext.Current.CancellationToken; @@ -465,7 +543,7 @@ public async Task TestIntegrationV2ToV2() using var receiveTransition = receiverBuilder.Build(); using var session = receiveTransition.Save(recvPersister); - var initial = await RetrieveReceiverProposal(session, receiver, recvPersister, ohttpRelay, cancellationToken); + var initial = await RetrieveReceiverProposal(session, receiver, recvPersister, ohttpRelay, mode, cancellationToken); Assert.Null(initial); // ***************************** @@ -500,7 +578,7 @@ public async Task TestIntegrationV2ToV2() // ********************* // RECEIVER SIDE // Poll for the 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 b40d40ea9..725469254 100644 --- a/payjoin-ffi/dart/test/test_payjoin_integration_test.dart +++ b/payjoin-ffi/dart/test/test_payjoin_integration_test.dart @@ -15,6 +15,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; @@ -202,91 +204,184 @@ 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: IsScriptOwnedCallback(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: IsScriptOwnedCallback(receiver)) + .save(persister: recv_persister); + } else { + final markedChecklist = proposal + .inputsOwnedChecklist() + .map( + (item) => item.mark( + result: IsScriptOwnedCallback(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 can_broadcast = MempoolAcceptanceCallback( + receiver, + ).callback(proposal.extractTxToCheckBroadcastSuitability()); + unchecked_proposal = proposal + .applyBroadcastSuitability( + minFeeRateSatPerKwu: null, + canBroadcast: can_broadcast, + ) + .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); @@ -303,7 +398,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"); @@ -313,12 +408,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; @@ -327,25 +424,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; @@ -354,6 +467,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( @@ -471,176 +745,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 0dde4a267..00ee2366d 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; @@ -225,13 +227,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">; } @@ -266,41 +278,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 IsScriptOwnedCallback(this.receiver)) - .save(this.recvPersister) as PJ<"MaybeInputsSeen">; + let maybeInputsSeen: PJ<"MaybeInputsSeen">; + + if (this.mode === "callback") { + maybeInputsSeen = proposal + .checkInputsNotOwned(new IsScriptOwnedCallback(this.receiver)) + .save(this.recvPersister) as PJ<"MaybeInputsSeen">; + } else { + const markedChecklist = proposal + .inputsOwnedChecklist() + .map((item) => + item.mark( + new IsScriptOwnedCallback(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); } @@ -491,7 +571,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(); @@ -513,6 +596,7 @@ async function testIntegrationV2ToV2(payjoin: PayjoinModule): Promise { payjoin, receiver, recvPersister, + mode, ); const senderPersister = new InMemorySenderPersister(); @@ -644,11 +728,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 146fc41fe..eba3703ae 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( - IsScriptOwnedCallback(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( + IsScriptOwnedCallback(self.receiver) + ).save(recv_persister) + else: + marked_checklist = [ + item.mark(IsScriptOwnedCallback(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 bc62bf73d..8b379553d 100644 --- a/payjoin-ffi/src/receive/mod.rs +++ b/payjoin-ffi/src/receive/mod.rs @@ -411,9 +411,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 { @@ -461,7 +458,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, @@ -525,6 +522,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. @@ -795,6 +795,32 @@ impl UncheckedOriginalPayload { ))))) } + /// Extract the transaction from the Original PSBT for external broadcast suitability checks. + /// + /// 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. + /// + /// 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, + can_broadcast: 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, can_broadcast), + ))))) + } + /// Call this method if the only way to initiate a Payjoin with this receiver /// requires manual intervention, as in most consumer wallets. /// @@ -807,6 +833,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) -> Vec { self.0.value().to_bytes() } + pub fn mark(&self, result: bool) -> Arc { + Arc::new(MarkedInputOwnedChecklistItem { value: self.value(), result }) + } +} + +#[derive(Debug, Clone, uniffi::Object)] +pub struct MarkedInputOwnedChecklistItem { + value: Vec, + result: bool, +} + +impl FfiMarkedChecklistItem> for MarkedInputOwnedChecklistItem { + fn result(&self) -> bool { self.result } + fn value(&self) -> Vec { self.value.clone() } +} + #[derive(Clone, uniffi::Object)] pub struct MaybeInputsOwned(payjoin::receive::v2::Receiver); @@ -860,6 +945,64 @@ 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, + ) -> Result>, ReceiverError> { + self.0 + .clone() + .inputs_owned_checklist() + .map(|iter| { + iter.map(|item| Arc::new(InputOwnedChecklistItem(item))).collect::>() + }) + .map_err(ReceiverError::from) + } + + /// 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. + /// + /// 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)] @@ -911,6 +1054,60 @@ 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). + /// + /// 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. @@ -960,6 +1157,36 @@ 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. + /// + /// 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)] @@ -1247,6 +1474,20 @@ impl ProvisionalProposal { } pub fn psbt_to_sign(&self) -> String { self.0.clone().psbt_to_sign().to_string() } + + /// Finalize the proposal with a signed PSBT. + /// + /// 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)] @@ -1521,6 +1762,50 @@ impl Monitor { }, ))))) } + + /// Returns the txid of the fallback transaction. + pub fn extract_fallback_txid(&self) -> String { + self.0.clone().extract_fallback_txid().to_string() + } + + /// Returns the txid of the payjoin proposal transaction. + pub fn extract_payjoin_proposal_txid(&self) -> String { + self.0.clone().extract_payjoin_proposal_txid().to_string() + } + + /// Check whether the fallback transaction can be monitored. If the + /// fallback transaction includes non-SegWit inputs, the fallback + /// transaction ID can change when the sender signs again, making + /// monitoring impossible and concluding the session. + /// + /// Returns a [`MonitorTransition`] that, once persisted, yields a + /// [`Monitor`] to continue monitoring or completes the session if + /// monitoring is not possible. + pub fn check_fallback_monitorable(&self) -> MonitorTransition { + MonitorTransition(Arc::new(RwLock::new(Some(self.0.clone().check_fallback_monitorable())))) + } + + /// Signal that the fallback transaction exists on the network, + /// completing the session. + /// + /// Returns a [`MonitorTransition`] that, once persisted, completes + /// the session. + pub fn fallback_tx_exists(&self) -> MonitorTransition { + MonitorTransition(Arc::new(RwLock::new(Some(self.0.clone().fallback_tx_exists())))) + } + + /// Signal that the payjoin transaction exists on the network, + /// completing the session. + /// + /// Returns a [`MonitorTransition`] that, once persisted, completes + /// the session. + 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)))))) + } } #[derive(uniffi::Object)] From 40bd7232a3c47097fdbea8a8887674053945b8ad Mon Sep 17 00:00:00 2001 From: xstoicunicornx Date: Wed, 20 May 2026 13:49:55 -0500 Subject: [PATCH 3/4] 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 | 99 ++++++++++++++++++----------------- 2 files changed, 71 insertions(+), 62 deletions(-) diff --git a/payjoin-cli/src/app/v1.rs b/payjoin-cli/src/app/v1.rs index 017abbf2a..6f77cfaa4 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, UriExt}; use tokio::net::TcpListener; @@ -348,33 +348,38 @@ 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 |input| { + let checklist = proposal.inputs_owned_checklist()?; + let marked_checklist = mark_checklist(checklist, &mut |input| { wallet.is_mine(input).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( @@ -394,11 +399,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 00d8eea52..efa898a3b 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}; 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, @@ -823,13 +824,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)?; println!("Fallback transaction received. Consider broadcasting this to get paid if the Payjoin fails:"); println!("{}", serialize_hex(&proposal.extract_tx_to_schedule_broadcast())); @@ -842,13 +841,11 @@ impl App { persister: &ReceiverPersister, ) -> Result<()> { let wallet = self.wallet(); - let proposal = proposal - .check_inputs_not_owned(&mut |input| { - wallet - .is_mine(input) - .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 |input| { + wallet.is_mine(input).map_err(|e| ImplementationError::from(e.into_boxed_dyn_error())) + })?; + let proposal = proposal.apply_inputs_owned_checklist(marked_checklist).save(persister)?; self.check_no_inputs_seen_before(proposal, persister).await } @@ -857,11 +854,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)?; self.identify_receiver_outputs(proposal, persister).await } @@ -871,13 +868,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)?; self.commit_outputs(proposal, persister).await } @@ -925,13 +922,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)?; self.send_payjoin_proposal(proposal, persister).await } @@ -971,24 +966,32 @@ 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())) + }; + match proposal.check_fallback_monitorable().save(persister)? { + OptionalTransitionOutcome::Progress(_) => { + println!("Unable to monitor for fallback tx containing non-segwit inputs, completing session"); + return Ok(()); + } + OptionalTransitionOutcome::Stasis(_) => {} + } 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(())) => { - println!("Payjoin transaction detected in the mempool!"); - return Ok(()); - } - Ok(OptionalTransitionOutcome::Stasis(_)) => continue, - Err(_) => continue, + 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(()); } } }) From 0c7344a4470500b1c8657cf01062c1a27c39c922 Mon Sep 17 00:00:00 2001 From: xstoicunicornx Date: Sat, 23 May 2026 12:00:42 -0500 Subject: [PATCH 4/4] Remove unused PsbtContext::finalize_proposal --- payjoin/src/core/receive/common/mod.rs | 2 +- payjoin/src/core/receive/mod.rs | 13 ------------- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/payjoin/src/core/receive/common/mod.rs b/payjoin/src/core/receive/common/mod.rs index 60ad7f337..41815a4c9 100644 --- a/payjoin/src/core/receive/common/mod.rs +++ b/payjoin/src/core/receive/common/mod.rs @@ -887,7 +887,7 @@ mod tests { .commit_inputs() .calculate_psbt_context_with_fee_range(None, None) .expect("Contributed inputs should allow for valid fee contributions"); - psbt_context.finalize_proposal(|_| Ok(processed_psbt.clone())).expect("Valid psbt") + psbt_context.finalize_signed_proposal(processed_psbt.clone()).expect("Valid psbt") } #[test] diff --git a/payjoin/src/core/receive/mod.rs b/payjoin/src/core/receive/mod.rs index d3be7a017..0e831ab57 100644 --- a/payjoin/src/core/receive/mod.rs +++ b/payjoin/src/core/receive/mod.rs @@ -442,19 +442,6 @@ impl PsbtContext { psbt } - /// Finalizes the Payjoin proposal into a PSBT which the sender will find acceptable before - /// they sign the transaction and broadcast it to the network. - /// - /// Finalization consists of 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)?; - self.finalize_signed_proposal(signed_psbt) - } - /// Finalizes the signed payjoin proposal PSBT which the sender will find acceptable before /// they sign the transaction and broadcast it to the network. ///