From a69ab27a629bf42fa7437b5186030a6b3d97660e Mon Sep 17 00:00:00 2001 From: xstoicunicornx Date: Wed, 17 Jun 2026 13:58:41 -0500 Subject: [PATCH 1/3] Add comments to FFI interface --- payjoin-ffi/src/receive/mod.rs | 84 ++++++++++++++++++++++++++++++++-- 1 file changed, 81 insertions(+), 3 deletions(-) diff --git a/payjoin-ffi/src/receive/mod.rs b/payjoin-ffi/src/receive/mod.rs index 9daa240d5..d6b3cf272 100644 --- a/payjoin-ffi/src/receive/mod.rs +++ b/payjoin-ffi/src/receive/mod.rs @@ -159,6 +159,9 @@ macro_rules! impl_terminal_cancel_for_receiver { #[uniffi::export] impl $ty { /// Cancel the Payjoin session immediately. + /// + /// Returns an [`CancelTransition`] that, once persisted, closes the + /// the receiver session. pub fn cancel(&self) -> CancelTransition { let transition = self.0.clone().cancel(); CancelTransition { @@ -174,6 +177,9 @@ macro_rules! impl_pending_fallback_cancel_for_receiver { #[uniffi::export] impl $ty { /// Cancel the Payjoin session and return pending fallback handling. + /// + /// Returns an [`CancelTransition`] that, once persisted, yields a + /// [`ReceiverPendingFallback`]. pub fn cancel(&self) -> CancelTransition { let transition = self.0.clone().cancel(); CancelTransition { @@ -201,6 +207,10 @@ impl_pending_fallback_cancel_for_receiver!(Monitor); #[uniffi::export] impl HasReplyableError { /// Cancel the Payjoin session without posting the receiver error response. + /// + /// Returns an [`CancelTransition`] that, once persisted, yields either + /// a [`ReceiverPendingFallback`] if current session has validated fallback tx, + /// or otherwise closes the session. pub fn cancel(&self) -> CancelTransition { let transition = self.0.clone().cancel(); CancelTransition { @@ -699,7 +709,11 @@ impl Initialized { .map_err(Into::into) } - /// The response can either be an UncheckedOriginalPayload or an ACCEPTED message indicating no UncheckedOriginalPayload is available yet. + /// Process the polling response from the directory. + /// + /// Returns an [`InitializedTransition`] that, once persisted, yields either + /// an [`UncheckedOriginalPayload`] if the sender's Original PSBT is available, + /// or [`Initialized`] if no proposal has arrived yet. pub fn process_response(&self, body: &[u8], ctx: &ClientResponse) -> InitializedTransition { InitializedTransition(Arc::new(RwLock::new(Some( self.0.clone().process_response(body, ctx.into()), @@ -781,6 +795,11 @@ pub trait CanBroadcast: Send + Sync { #[uniffi::export] impl UncheckedOriginalPayload { + /// Check that the sender's Original PSBT is suitable for broadcast, ensuring + /// it can be used as a fallback if the payjoin does not complete. + /// + /// Returns an [`UncheckedOriginalPayloadTransition`] that, once persisted, + /// yields a [`MaybeInputsOwned`] to continue validation. pub fn check_broadcast_suitability( &self, min_fee_rate_sat_per_kwu: Option, @@ -846,12 +865,21 @@ pub trait IsScriptOwned: Send + Sync { #[uniffi::export] impl MaybeInputsOwned { - /// The Sender’s Original PSBT + /// Extract the transaction from the Original PSBT for scheduling broadcast as a + /// fallback in case the payjoin does not complete. + /// + /// Returns the consensus-encoded raw transaction bytes. pub fn extract_tx_to_schedule_broadcast(&self) -> Vec { payjoin::bitcoin::consensus::encode::serialize( &self.0.clone().extract_tx_to_schedule_broadcast(), ) } + + /// Check that none of the Original PSBT's inputs belong to the receiver, + /// preventing an attacker from spending the receiver's own inputs. + /// + /// Returns a [`MaybeInputsOwnedTransition`] that, once persisted, + /// yields a [`MaybeInputsSeen`] to continue validation. pub fn check_inputs_not_owned( &self, is_owned: Arc, @@ -902,6 +930,12 @@ pub trait IsOutputKnown: Send + Sync { #[uniffi::export] impl MaybeInputsSeen { + /// Check that none of the inputs have been seen before, preventing 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 check_no_inputs_seen_before( &self, is_known: Arc, @@ -951,7 +985,11 @@ impl_save_for_transition!(OutputsUnknownTransition, WantsOutputs); #[uniffi::export] impl OutputsUnknown { - /// Find which outputs belong to the receiver + /// Identify which outputs in the original transaction belong to the receiver + /// and ensure at least one output pays the receiver. + /// + /// Returns an [`OutputsUnknownTransition`] that, once persisted, + /// yields a [`WantsOutputs`] to continue the proposal. pub fn identify_receiver_outputs( &self, is_receiver_output: Arc, @@ -994,8 +1032,14 @@ impl_save_for_transition!(WantsOutputsTransition, WantsInputs); #[uniffi::export] impl WantsOutputs { + /// Returns whether output substitution is enabled for this session. pub fn output_substitution(&self) -> OutputSubstitution { self.0.output_substitution() } + /// Replace all receiver outputs with the provided `replacement_outputs`, + /// and set up the `drain_script` as the receiver-owned output whose value + /// may be adjusted based on modifications in subsequent states. + /// + /// Returns an updated [`WantsOutputs`] with the replaced outputs. pub fn replace_receiver_outputs( &self, replacement_outputs: Vec, @@ -1013,6 +1057,9 @@ impl WantsOutputs { .map_err(Into::into) } + /// Substitute the receiver output script with the provided script. + /// + /// Returns an updated [`WantsOutputs`] with the substituted output. pub fn substitute_receiver_script( &self, output_script_pubkey: Vec, @@ -1026,6 +1073,10 @@ impl WantsOutputs { .map_err(Into::into) } + /// Commit the output modifications and proceed to input contribution. + /// + /// Returns a [`WantsOutputsTransition`] that, once persisted, + /// yields a [`WantsInputs`]. pub fn commit_outputs(&self) -> WantsOutputsTransition { WantsOutputsTransition(Arc::new(RwLock::new(Some(self.0.clone().commit_outputs())))) } @@ -1082,6 +1133,9 @@ impl WantsInputs { } } + /// Add the provided inputs to the payjoin proposal. + /// + /// Returns an updated [`WantsInputs`] with the contributed inputs. pub fn contribute_inputs( &self, replacement_inputs: Vec>, @@ -1095,6 +1149,10 @@ impl WantsInputs { .map_err(Into::into) } + /// Commit the input contributions and proceed to fee negotiation. + /// + /// Returns a [`WantsInputsTransition`] that, once persisted, + /// yields a [`WantsFeeRange`]. pub fn commit_inputs(&self) -> WantsInputsTransition { WantsInputsTransition(Arc::new(RwLock::new(Some(self.0.clone().commit_inputs())))) } @@ -1239,6 +1297,10 @@ pub trait ProcessPsbt: Send + Sync { #[uniffi::export] impl ProvisionalProposal { + /// Finalize the proposal by signing the PSBT via the `process_psbt` callback. + /// + /// Returns a [`ProvisionalProposalTransition`] that, once persisted, + /// yields the final [`PayjoinProposal`]. pub fn finalize_proposal( &self, process_psbt: Arc, @@ -1253,6 +1315,7 @@ impl ProvisionalProposal { )))) } + /// Extract the PSBT that needs to be signed by the receiver's wallet. pub fn psbt_to_sign(&self) -> String { self.0.clone().psbt_to_sign().to_string() } } @@ -1297,6 +1360,7 @@ impl_save_for_transition!(PayjoinProposalTransition, Monitor); #[uniffi::export] impl PayjoinProposal { + /// Returns the finalized payjoin proposal PSBT. pub fn psbt(&self) -> String { , @@ -1416,6 +1480,8 @@ impl HasReplyableErrorTransition { #[uniffi::export] impl HasReplyableError { + /// Construct an OHTTP encapsulated POST request to post the receiver + /// error response to the directory so it can be retrieved by the sender. pub fn create_error_request( &self, ohttp_relay: String, @@ -1425,6 +1491,13 @@ impl HasReplyableError { }) } + /// Process the response from the directory after posting the receiver + /// error response. + /// + /// Returns a [`HasReplyableErrorTransition`] that, once persisted, + /// completes the error reporting and either yields a + /// [`ReceiverPendingFallback`] if current session has validated fallback tx, + /// or otherwise closes the session. pub fn process_error_response( &self, body: &[u8], @@ -1505,6 +1578,11 @@ fn try_deserialize_tx( #[uniffi::export] impl Monitor { + /// Check the network for the payjoin or fallback transaction via the + /// `find_transaction` callback. + /// + /// Returns a [`MonitorTransition`] that, once persisted, completes + /// the session if a transaction is found. pub fn check_for_transaction( &self, find_transaction: Arc, From 877c6d24d453823335efbc2e5f3acb8355dd34c1 Mon Sep 17 00:00:00 2001 From: xstoicunicornx Date: Fri, 3 Jul 2026 12:37:57 -0700 Subject: [PATCH 2/3] Revise v2 receiver typestate doc comments Rework the doc comments in the v2 receive module so the state-level comments are concise and point to the method that advances to the next typestate, while the per-method comments align with the wording added to the FFI interface. Method docs now name their explicit transition return type and describe the state each transition yields once successfully persisted. --- payjoin/src/core/receive/v2/mod.rs | 330 ++++++++++++++++------------- 1 file changed, 181 insertions(+), 149 deletions(-) diff --git a/payjoin/src/core/receive/v2/mod.rs b/payjoin/src/core/receive/v2/mod.rs index 542f5e23c..621880165 100644 --- a/payjoin/src/core/receive/v2/mod.rs +++ b/payjoin/src/core/receive/v2/mod.rs @@ -449,16 +449,29 @@ pub struct PendingFallback { fallback_tx: bitcoin::Transaction, } +/// Typestate holding the fallback transaction after the Payjoin session was cancelled or +/// failed. +/// +/// The receiver may broadcast the fallback transaction, then call +/// [`Receiver::close`] to close the session. impl Receiver { + /// Returns the fallback transaction to be broadcast. pub fn fallback_tx(&self) -> &bitcoin::Transaction { &self.state.fallback_tx } + /// Close the Payjoin session. + /// + /// Returns a [`TerminalTransition`] that, once successfully persisted, closes the + /// receiver session. pub fn close(self) -> TerminalTransition { TerminalTransition::new(SessionEvent::Closed(SessionOutcome::Aborted), ()) } } impl Receiver { - /// Cancel the Payjoin session and surface the fallback transaction. + /// Cancel the Payjoin session and return pending fallback handling. + /// + /// Returns a [`NextStateTransition`] that, once successfully persisted, yields a + /// [`Receiver`]. pub fn cancel(self) -> NextStateTransition> { let fallback_tx = self.state.fallback_tx(); NextStateTransition::success( @@ -472,7 +485,10 @@ impl Receiver { } impl Receiver { - /// Cancel before any fallback transaction exists. + /// Cancel the Payjoin session immediately. + /// + /// Returns a [`TerminalTransition`] that, once successfully persisted, closes the + /// receiver session. pub fn cancel(self) -> TerminalTransition { TerminalTransition::new(SessionEvent::Closed(SessionOutcome::Aborted), ()) } @@ -541,16 +557,16 @@ impl ReceiverBuilder { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Initialized {} +/// The initial typestate of a Payjoin v2 receiver session. +/// +/// After sharing the Payjoin URI from [`Receiver::pj_uri`] with the +/// sender out of band, poll the Payjoin Directory in the PJ URI for the sender's +/// Original PSBT: build a request with [`Receiver::create_poll_request`], +/// then pass each response to [`Receiver::process_response`] until it +/// advances to [`Receiver`]. impl Receiver { - /// Create an OHTTP encapsulated HTTP GET request to poll for the Original PSBT - /// from the Payjoin Directory. - /// - /// After the receiver extracts the Payjoin URI with [`Receiver::pj_uri`] and sends it - /// to the sender, they should poll the Payjoin Directory in the PJ URI for the sender's - /// Original PSBT. - /// - /// Requests created with this function are OHTTP encapsulated for the configured directory and - /// addressed to the `ohttp_relay` parameter. + /// Construct an OHTTP encapsulated GET request to be used to poll the Payjoin + /// Directory for the sender's Original PSBT. pub fn create_poll_request( &self, ohttp_relay: impl IntoUrl, @@ -563,14 +579,12 @@ impl Receiver { Ok((req, OhttpResponse::new(ohttp_ctx))) } - /// Process the response to the Original PSBT poll from the Payjoin Directory. - /// - /// The response can either be an [`UncheckedOriginalPayload`] or an ACCEPTED message - /// indicating no [`UncheckedOriginalPayload`] is available yet. + /// Process the Payjoin directory polling response. /// - /// If the response contains the Original PSBT from the sender, transition to the next - /// typestate. If the response is an ACCEPTED message from the directory which indicates that - /// no payload is available yet, continue to poll. + /// Returns a [`MaybeFatalTransitionWithNoResults`] that, once successfully + /// persisted, yields either a [`Receiver`] if the + /// sender's Original PSBT is available, or a [`Receiver`] to remain + /// in stasis if no proposal has arrived yet. pub fn process_response( self, body: &[u8], @@ -714,52 +728,39 @@ impl Receiver { } } -/// The sender's original PSBT and optional parameters -/// -/// This type is used to process the request. It is returned by -/// [`Receiver::process_response()`]. +/// This is the first typestate after retrieving the sender's proposal. Here the +/// receiver verifies the Original PSBT is broadcastable so it can serve as a +/// fallback if the payjoin fails. /// +/// Non-interactive receivers (e.g. a donation page that generates a fresh QR code +/// per visit) should call +/// [`Receiver::check_broadcast_suitability`] to confirm +/// the proposal is broadcastable (and optionally above a minimum fee rate), +/// guarding against probing attacks that trick the receiver into revealing its +/// UTXOs. Interactive receivers can skip that check and call +/// [`Receiver::assume_interactive_receiver`] instead. +/// Either path advances to [`Receiver`]. #[derive(Debug, Clone, PartialEq)] pub struct UncheckedOriginalPayload { pub(crate) original: OriginalPayload, } impl Receiver { - /// Cancel before broadcast suitability has been checked. + /// Cancel the Payjoin session immediately. + /// + /// Returns a [`TerminalTransition`] that, once successfully persisted, closes the + /// receiver session. pub fn cancel(self) -> TerminalTransition { TerminalTransition::new(SessionEvent::Closed(SessionOutcome::Aborted), ()) } } -/// The original PSBT and the optional parameters received from the sender. -/// -/// This is the first typestate after the retrieval of the sender's original proposal in -/// the receiver's workflow. At this stage, the receiver can verify that the original PSBT they have -/// received from the sender is broadcastable to the network in the case of a payjoin failure. -/// -/// The recommended usage of this typestate differs based on whether you are implementing an -/// interactive (where the receiver takes manual actions to respond to the -/// payjoin proposal) or a non-interactive (ex. a donation page which automatically generates a new QR code -/// for each visit) payment receiver. For the latter, you should call [`Receiver::check_broadcast_suitability`] to check -/// that the proposal is actually broadcastable (and, optionally, whether the fee rate is above the -/// minimum limit you have set). These mechanisms protect the receiver against probing attacks, where -/// a malicious sender can repeatedly send proposals to have the non-interactive receiver reveal the UTXOs -/// it owns with the proposals it modifies. -/// -/// If you are implementing an interactive payment receiver, then such checks are not necessary, and you -/// can go ahead with calling [`Receiver::assume_interactive_receiver`] to move on to the next typestate. impl Receiver { - /// Checks that the original PSBT in the proposal can be broadcasted. - /// - /// If the receiver is a non-interactive payment processor (ex. a donation page which generates - /// a new QR code for each visit), then it should make sure that the original PSBT is broadcastable - /// as a fallback mechanism in case the payjoin fails. This validation would be equivalent to - /// `testmempoolaccept` RPC call returning `{"allowed": true,...}`. + /// Check that the sender's Original PSBT is suitable for broadcast, ensuring + /// it can be used as a fallback if the payjoin does not complete. /// - /// 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. + /// Returns a [`MaybeFatalTransition`] that, once successfully persisted, yields a + /// [`Receiver`] to continue validation. pub fn check_broadcast_suitability( self, min_fee_rate: Option, @@ -792,10 +793,13 @@ impl Receiver { } } - /// Moves on to the next typestate without any of the current typestate's validations. + /// Skip the current typestate's validations. + /// + /// Use this for interactive receivers, which manually create Payjoin URIs and so + /// are not exposed to the probing attacks the checks guard against. /// - /// Use this for interactive payment receivers, where there is no risk of a probing attack since the - /// receiver needs to manually create payjoin URIs. + /// Returns a [`NextStateTransition`] that, once successfully persisted, yields a + /// [`Receiver`]. pub fn assume_interactive_receiver( self, ) -> NextStateTransition> { @@ -822,26 +826,28 @@ pub struct MaybeInputsOwned { original: OriginalPayload, } -/// Typestate to check that the original PSBT has no inputs owned by the receiver. +/// Typestate to check that the Original PSBT has no inputs owned by the receiver. /// -/// At this point, it has been verified that the transaction is broadcastable from previous -/// typestate. The receiver can call [`Receiver::extract_tx_to_schedule_broadcast`] -/// to extract the signed original PSBT to schedule a fallback in case the Payjoin process fails. +/// At this point, the Original PSBT has been verified as broadcastable; the receiver +/// can call [`Receiver::extract_tx_to_schedule_broadcast`] to +/// schedule a fallback broadcast in case the payjoin fails. /// -/// Call [`Receiver::check_inputs_not_owned`] to proceed. +/// Call [`Receiver::check_inputs_not_owned`] to advance to +/// [`Receiver`] to continue validation. impl Receiver { - /// Extracts the original transaction received from the sender. + /// Extract the transaction from the Original PSBT for scheduling broadcast as a + /// fallback in case the payjoin does not complete. /// - /// Use this for scheduling the broadcast of the original transaction as a fallback - /// for the payjoin. Note that this function does not make any validation on whether - /// the transaction is broadcastable; it simply extracts it. + /// Returns the extracted [`bitcoin::Transaction`]. pub fn extract_tx_to_schedule_broadcast(&self) -> bitcoin::Transaction { self.state.fallback_tx() } - /// Check that the original PSBT has no receiver-owned inputs. + /// Check that none of the Original PSBT's inputs belong to the receiver, + /// preventing an attacker from spending the receiver's own inputs. /// - /// An attacker can try to spend the receiver's own inputs. This check prevents that. + /// Returns a [`MaybeFatalTransition`] that, once successfully persisted, yields a + /// [`Receiver`] to continue validation. pub fn check_inputs_not_owned( self, is_owned: &mut impl FnMut(&Script) -> Result, @@ -891,18 +897,24 @@ pub struct MaybeInputsSeen { original: OriginalPayload, } -/// Typestate to check that the original PSBT has no inputs that the receiver has seen before. +/// Typestate to check that the Original PSBT has no inputs the receiver has seen before. /// -/// Call [`Receiver::check_no_inputs_seen_before`] to proceed. +/// This check prevents the following attacks: +/// 1. Probing attacks, where the sender uses 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 PSBT of the current, new payjoin. +/// +/// Call [`Receiver::check_no_inputs_seen_before`] to advance to +/// [`Receiver`] to continue validation. impl Receiver { - /// Check that the receiver has never seen the inputs in the original proposal before. + /// Check that none of the inputs have been seen before, preventing input + /// probing and replay attacks (where inputs have been used in a previous + /// payjoin attempt). /// - /// 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. + /// Returns a [`MaybeFatalTransition`] that, once successfully persisted, yields a + /// [`Receiver`] to continue validation. pub fn check_no_inputs_seen_before( self, is_known: &mut impl FnMut(&OutPoint) -> Result, @@ -952,23 +964,20 @@ pub struct OutputsUnknown { original: OriginalPayload, } -/// Typestate to check that the outputs of the original PSBT actually pay to the receiver. -/// -/// The receiver should only accept the original PSBTs from the sender which actually send them -/// money. +/// Typestate to check that the outputs of the Original PSBT actually pay the receiver. /// -/// Call [`Receiver::identify_receiver_outputs`] to proceed. +/// The receiver should only accept Original PSBTs from the sender that actually send +/// them money. Call [`Receiver::identify_receiver_outputs`] to advance +/// to [`Receiver`] to continue the proposal. impl Receiver { - /// Validates whether the original PSBT contains outputs which pay to the receiver and only - /// then proceeds to the next typestate. + /// Identify which outputs in the original transaction belong to the receiver + /// and ensure at least one output pays the receiver. /// - /// 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. + /// If the sender designated a receiver output for fee subtraction, that designation + /// is cleared so the receiver does not accidentally subtract fees from their own output. + /// + /// Returns a [`MaybeFatalTransition`] that, once successfully persisted, yields a + /// [`Receiver`] to continue the proposal. pub fn identify_receiver_outputs( self, is_receiver_output: &mut impl FnMut(&Script) -> Result, @@ -1024,19 +1033,19 @@ pub struct WantsOutputs { inner: common::WantsOutputs, } -/// Typestate which the receiver may substitute or add outputs to. -/// -/// In addition to contributing new inputs to an existing PSBT, Payjoin allows the -/// receiver to substitute the original PSBT's outputs to potentially preserve privacy and batch transfers. -/// The receiver does not have to limit themselves to the address shared with the sender in the -/// original Payjoin URI, and can make substitutions of the existing outputs in the proposal. +/// Typestate for a checked proposal that the receiver may add or substitute outputs to. /// -/// Call [`Receiver::commit_outputs`] to proceed. +/// Beyond contributing new inputs, Payjoin lets the receiver substitute the Original +/// PSBT's outputs to preserve privacy or perform batch transfers; the receiver is not +/// limited to the address shared in the Payjoin URI. Call +/// [`Receiver::commit_outputs`] to advance to [`Receiver`]. impl Receiver { - /// Whether the receiver is allowed to substitute original outputs or not. + /// Returns whether output substitution is enabled for this session. pub fn output_substitution(&self) -> OutputSubstitution { self.inner.output_substitution() } /// Substitute the receiver output script with the provided script. + /// + /// Returns an updated [`Receiver`] with the substituted output. pub fn substitute_receiver_script( self, output_script: &Script, @@ -1045,19 +1054,15 @@ impl Receiver { Ok(Receiver { state: WantsOutputs { inner }, session_context: self.session_context }) } - /// Replaces **all** receiver outputs with the one or more provided `replacement_outputs`, and - /// sets up the passed `drain_script` as the receiver-owned output which might have its value - /// adjusted based on the modifications the receiver makes in the subsequent typestates. + /// Replace all receiver outputs with the provided `replacement_outputs`, and set up + /// the `drain_script` as the receiver-owned output whose value may be adjusted based + /// on modifications in subsequent states. /// - /// Sender's outputs are not touched. Existing receiver outputs will be replaced with the - /// outputs in the `replacement_outputs` argument. The number of replacement outputs should - /// match or exceed the number of receiver outputs in the original proposal PSBT. + /// For example, when the receiver contributes an input, the drain output's value is + /// increased by the same amount; when an output's value must be reduced to cover fees, + /// it is taken from the drain output. /// - /// The drain script is the receiver script which will have its value adjusted based on the - /// modifications the receiver makes on the transaction in the subsequent typestates. For - /// example, if the receiver adds their own input, then the drain script output will have its - /// value increased by the same amount. Or if an output needs to have its value reduced to - /// account for fees, the value of the output for this script will be reduced. + /// Returns an updated [`Receiver`] with the replaced outputs. pub fn replace_receiver_outputs( self, replacement_outputs: impl IntoIterator, @@ -1067,9 +1072,10 @@ impl Receiver { Ok(Receiver { state: WantsOutputs { inner }, session_context: self.session_context }) } - /// Commits the outputs as final, and moves on to the next typestate. + /// Commit the output modifications and proceed to input contribution. /// - /// Outputs cannot be modified after this function is called. + /// Returns a [`NextStateTransition`] that, once successfully persisted, yields a + /// [`Receiver`]. pub fn commit_outputs(self) -> NextStateTransition> { let inner = self.state.inner.commit_outputs(); // change_vout, chosen by the output shuffle, can't be recomputed on replay. @@ -1113,9 +1119,12 @@ pub struct WantsInputs { inner: common::WantsInputs, } -/// Typestate for a checked proposal which the receiver may contribute inputs to. +/// Typestate for a checked proposal that the receiver may contribute inputs to. /// -/// Call [`Receiver::commit_inputs`] to proceed. +/// Optionally pick a privacy-preserving input with +/// [`Receiver::try_preserving_privacy`], add inputs with +/// [`Receiver::contribute_inputs`], then call +/// [`Receiver::commit_inputs`] to advance to [`Receiver`]. impl Receiver { /// Selects and returns an input from `candidate_inputs` which will preserve the receiver's privacy by /// avoiding the Unnecessary Input Heuristic 2 (UIH2) outlined in [Unnecessary Input @@ -1131,9 +1140,10 @@ impl Receiver { self.inner.try_preserving_privacy(candidate_inputs) } - /// Contributes the provided list of inputs to the transaction at random indices. If the total input - /// amount exceeds the total output amount after the contribution, adds all excess amount to - /// the receiver change output. + /// Add the provided inputs to the payjoin proposal at random indices. Any input value + /// exceeding the total output amount is added to the receiver's change output. + /// + /// Returns an updated [`Receiver`] with the contributed inputs. pub fn contribute_inputs( self, inputs: impl IntoIterator, @@ -1142,9 +1152,10 @@ impl Receiver { Ok(Receiver { state: WantsInputs { inner }, session_context: self.session_context }) } - /// Commits the inputs as final, and moves on to the next typestate. + /// Commit the input contributions and proceed to fee negotiation. /// - /// Inputs cannot be modified after this function is called. + /// Returns a [`NextStateTransition`] that, once successfully persisted, yields a + /// [`Receiver`]. pub fn commit_inputs(self) -> NextStateTransition> { let inner = self.state.inner.commit_inputs(); // The RNG insert order and change bump aren't reconstructable; store the PSBT. @@ -1186,6 +1197,11 @@ pub struct WantsFeeRange { inner: common::WantsFeeRange, } +/// Typestate for a checked proposal that applies additional fee contribution for the +/// receiver contributed inputs and outputs. +/// +/// Call [`Receiver::apply_fee_range`] to advance to +/// [`Receiver`]. impl Receiver { /// Applies additional fee contribution now that the receiver has contributed inputs /// and may have added new outputs. @@ -1207,6 +1223,9 @@ impl Receiver { /// /// The minimum effective fee limit is the highest of the minimum limit set by the sender in /// the original proposal parameters and the limit passed in the `min_fee_rate` parameter. + /// + /// Returns a [`MaybeFatalTransition`] that, once successfully persisted, yields a + /// [`Receiver`]. pub fn apply_fee_range( self, min_fee_rate: Option, @@ -1247,16 +1266,16 @@ pub struct ProvisionalProposal { psbt_context: PsbtContext, } -/// Typestate for a checked proposal which had both the outputs and the inputs modified -/// by the receiver. The receiver may sign and finalize the Payjoin proposal which will be sent to -/// the sender for their signature. +/// Typestate for a checked proposal that the receiver has modified the outputs and +/// inputs of, and is ready to be signed and finalized. /// -/// Call [`Receiver::finalize_proposal`] to return a finalized [`PayjoinProposal`]. +/// Call [`Receiver::finalize_proposal`] to advance to +/// [`Receiver`]. impl Receiver { - /// Finalizes the Payjoin proposal into a PSBT which the sender will find acceptable before - /// they re-sign the transaction and broadcast it to the network. + /// Finalize the proposal by signing the PSBT via the `wallet_process_psbt` callback. /// - /// Finalization consists of signing and finalizing the PSBT using the passed `wallet_process_psbt` signing function. + /// Returns a [`MaybeTransientTransition`] that, once successfully persisted, yields the + /// final [`Receiver`]. pub fn finalize_proposal( self, wallet_process_psbt: impl Fn(&Psbt) -> Result, @@ -1280,11 +1299,13 @@ impl Receiver { ) } - /// The Payjoin proposal PSBT that the receiver needs to sign + /// Extract the PSBT that needs to be signed by the receiver's wallet. + /// + /// In some applications the entity that progresses the typestate is different from the + /// entity that has access to the private keys, so the PSBT to sign must be accessible to + /// such implementers. /// - /// In some applications the entity that progresses the typestate - /// is different from the entity that has access to the private keys, - /// so the PSBT to sign must be accessible to such implementers. + /// Returns the Payjoin proposal [`Psbt`] to be signed. pub fn psbt_to_sign(&self) -> Psbt { self.state.psbt_context.psbt_to_sign() } pub(crate) fn apply_payjoin_proposal(self, payjoin_psbt: Psbt) -> ReceiveSession { @@ -1305,10 +1326,15 @@ pub struct PayjoinProposal { psbt_context: PsbtContext, } -/// A finalized Payjoin proposal, complete with fees and receiver signatures, that the sender -/// should find acceptable. +/// Typestate for a signed and finalized Payjoin proposal that is to be sent to the +/// sender for them to sign and broadcast. +/// +/// Post the proposal to the Payjoin directory with +/// [`Receiver::create_post_request`], then submit the Payjoin directory +/// response to [`Receiver::process_response`] to advance to +/// [`Receiver`]. impl Receiver { - /// The Payjoin Proposal PSBT. + /// Returns the finalized payjoin proposal PSBT. pub fn psbt(&self) -> &Psbt { &self.psbt_context.payjoin_psbt } /// Construct an OHTTP-encapsulated HTTP request carrying the Proposal PSBT. @@ -1363,13 +1389,10 @@ impl Receiver { Ok((req, OhttpResponse::new(ctx))) } - /// Processes the response for the final POST message from the receiver client in the v2 Payjoin protocol. - /// - /// This function decapsulates the response using the provided OHTTP context. If the response status is successful, - /// it indicates that the Payjoin proposal has been accepted. Otherwise, it returns an error with the status code. + /// Process the Payjoin directory response to the posted Payjoin proposal. /// - /// After this function is called, the receiver can either wait for the Payjoin transaction to be broadcast or - /// choose to broadcast the original PSBT. + /// Returns a [`MaybeFatalTransition`] that, once successfully persisted, yields a + /// [`Receiver`] to watch for the payjoin transaction. pub fn process_response( self, res: &[u8], @@ -1422,8 +1445,18 @@ pub struct HasReplyableError { fallback_tx: Option, } +/// Typestate for a receiver that hit a replyable error during validation. +/// +/// Post the error to the Payjoin directory to communicate it to the sender with +/// [`Receiver::create_error_request`], then submit the Payjoin +/// directory response to [`Receiver::process_error_response`]. +/// Alternatively, call [`Receiver::cancel`] to skip posting the error. impl Receiver { - /// Cancel without sending the error response. + /// Cancel the Payjoin session without posting the replyable error to the Payjoin directory. + /// + /// Returns a [`MaybeTerminalTransition`] that, once successfully persisted, yields a + /// [`Receiver`] if the session has a validated fallback transaction, + /// or otherwise closes the session. pub fn cancel(self) -> MaybeTerminalTransition> { let Receiver { state: HasReplyableError { fallback_tx, .. }, session_context } = self; match fallback_tx { @@ -1436,8 +1469,8 @@ impl Receiver { } } - /// Construct an OHTTP Encapsulated HTTP POST request to return - /// a Receiver Error Response + /// Construct an OHTTP encapsulated POST request to post the replyable error to the + /// Payjoin directory so it can be retrieved by the sender. pub fn create_error_request( &self, ohttp_relay: impl IntoUrl, @@ -1469,8 +1502,11 @@ impl Receiver { Ok((req, OhttpResponse::new(ohttp_ctx))) } - /// Process an OHTTP Encapsulated HTTP POST Error response - /// to ensure it has been posted properly. + /// Process the Payjoin directory response to the posted replyable error. + /// + /// Returns a [`MaybeTerminalSuccessTransition`] that, once successfully persisted, + /// completes the error reporting and yields a [`Receiver`] if the + /// session has a validated fallback transaction, or otherwise closes the session. pub fn process_error_response( self, res: &[u8], @@ -1525,16 +1561,12 @@ pub struct Monitor { /// Call [`Receiver::check_for_transaction`] to confirm the status of the transaction in the /// network and conclude the Payjoin session. impl Receiver { - /// Checks the network for the Payjoin proposal or the fallback transaction using the passed - /// `find_transaction` closure, and concludes the Payjoin session once one is found. The - /// closure defines the condition that counts as found — for example presence in the mempool, - /// or some number of confirmations on the blockchain. + /// Check the network for the payjoin or fallback transaction via the `find_transaction` + /// callback. /// - /// If the input address type in the fallback transaction is non-SegWit, then this - /// function will directly conclude the Payjoin session with a Success without running the - /// provided `find_transaction` closure. `find_transaction` uses the transaction ID to - /// search for the transaction in the network. Since a non-SegWit input signature is going to - /// change the TXID of the Payjoin proposal, it cannot be monitored. + /// Returns a [`MaybeFatalOrSuccessTransition`] that, once successfully persisted, either + /// concludes the session if a transaction is found, or yields a [`Receiver`] to + /// remain in stasis if no transaction is found yet. pub fn check_for_transaction( self, find_transaction: impl Fn(Txid) -> Result, ImplementationError>, From 0cb183a9ed76b6e3dafb1852edb8689bd30b83f4 Mon Sep 17 00:00:00 2001 From: xstoicunicornx Date: Fri, 3 Jul 2026 13:00:53 -0700 Subject: [PATCH 3/3] Revise v1 receiver typestate doc comments Apply the same doc comment rework to the v1 receive module: state-level comments are concise and point to the method that advances to the next typestate, while per-method comments align with the wording used in the FFI interface. Unlike v2, v1 methods return the next state directly, so the docs describe the returned state without transition or persistence framing. --- payjoin/src/core/receive/v1/mod.rs | 150 ++++++++++++++--------------- 1 file changed, 73 insertions(+), 77 deletions(-) diff --git a/payjoin/src/core/receive/v1/mod.rs b/payjoin/src/core/receive/v1/mod.rs index a47cbd67b..83b643c06 100644 --- a/payjoin/src/core/receive/v1/mod.rs +++ b/payjoin/src/core/receive/v1/mod.rs @@ -70,40 +70,27 @@ impl UncheckedOriginalPayload { } } -/// The original PSBT and the optional parameters received from the sender. +/// This is the first typestate after retrieving the sender's proposal. Here the +/// receiver verifies the Original PSBT is broadcastable so it can serve as a +/// fallback if the payjoin fails. /// -/// This is the first typestate after the retrieval of the sender's original proposal in -/// the receiver's workflow. At this stage, the receiver can verify that the original PSBT they have -/// received from the sender is broadcastable to the network in the case of a payjoin failure. -/// -/// The recommended usage of this typestate differs based on whether you are implementing an -/// interactive (where the receiver takes manual actions to respond to the -/// payjoin proposal) or a non-interactive (ex. a donation page which automatically generates a new QR code -/// for each visit) payment receiver. For the latter, you should call [`Self::check_broadcast_suitability`] to check -/// that the proposal is actually broadcastable (and, optionally, whether the fee rate is above the -/// minimum limit you have set). These mechanisms protect the receiver against probing attacks, where -/// a malicious sender can repeatedly send proposals to have the non-interactive receiver reveal the UTXOs -/// it owns with the proposals it modifies. -/// -/// If you are implementing an interactive payment receiver, then such checks are not necessary, and you -/// can go ahead with calling [`Self::assume_interactive_receiver`] to move on to the next typestate. +/// Non-interactive receivers (e.g. a donation page that generates a fresh QR code +/// per visit) should call [`Self::check_broadcast_suitability`] to confirm the +/// proposal is broadcastable (and optionally above a minimum fee rate), guarding +/// against probing attacks that trick the receiver into revealing its UTXOs. +/// Interactive receivers can skip that check and call +/// [`Self::assume_interactive_receiver`] instead. Either path advances to +/// [`MaybeInputsOwned`]. #[derive(Debug, Clone)] pub struct UncheckedOriginalPayload { original: OriginalPayload, } impl UncheckedOriginalPayload { - /// Checks that the original PSBT in the proposal can be broadcasted. - /// - /// If the receiver is a non-interactive payment processor (ex. a donation page which generates - /// a new QR code for each visit), then it should make sure that the original PSBT is broadcastable - /// as a fallback mechanism in case the payjoin fails. This validation would be equivalent to - /// `testmempoolaccept` Bitcoin Core RPC call returning `{"allowed": true,...}`. + /// Check that the sender's Original PSBT is suitable for broadcast, ensuring + /// it can be used as a fallback if the payjoin does not complete. /// - /// 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. + /// Returns a [`MaybeInputsOwned`] to continue validation. pub fn check_broadcast_suitability( self, min_fee_rate: Option, @@ -113,40 +100,43 @@ impl UncheckedOriginalPayload { Ok(MaybeInputsOwned { original: self.original }) } - /// Moves on to the next typestate without any of the current typestate's validations. + /// Skip the current typestate's validations. /// - /// Use this for interactive payment receivers, where there is no risk of a probing attack since the - /// receiver needs to manually create payjoin URIs. + /// Use this for interactive receivers, which manually create Payjoin URIs and so + /// are not exposed to the probing attacks the checks guard against. + /// + /// Returns a [`MaybeInputsOwned`]. pub fn assume_interactive_receiver(self) -> MaybeInputsOwned { MaybeInputsOwned { original: self.original } } } -/// Typestate to check that the original PSBT has no inputs owned by the receiver. +/// Typestate to check that the Original PSBT has no inputs owned by the receiver. /// -/// At this point, it has been verified that the transaction is broadcastable from previous -/// typestate. The receiver can call [`Self::extract_tx_to_schedule_broadcast`] -/// to extract the signed original PSBT to schedule a fallback in case the Payjoin process fails. +/// At this point, the Original PSBT has been verified as broadcastable; the receiver +/// can call [`Self::extract_tx_to_schedule_broadcast`] to schedule a fallback broadcast +/// in case the payjoin fails. /// -/// Call [`Self::check_inputs_not_owned`] to proceed. +/// Call [`Self::check_inputs_not_owned`] to advance to [`MaybeInputsSeen`] to continue +/// validation. #[derive(Debug, Clone)] pub struct MaybeInputsOwned { pub(crate) original: OriginalPayload, } impl MaybeInputsOwned { - /// Extracts the original transaction received from the sender. + /// Extract the transaction from the Original PSBT for scheduling broadcast as a + /// fallback in case the payjoin does not complete. /// - /// Use this for scheduling the broadcast of the original transaction as a fallback - /// for the payjoin. Note that this function does not make any validation on whether - /// the transaction is broadcastable; it simply extracts it. + /// Returns the extracted [`bitcoin::Transaction`]. pub fn extract_tx_to_schedule_broadcast(&self) -> bitcoin::Transaction { self.original.psbt.clone().extract_tx_unchecked_fee_rate() } - /// Check that the original PSBT has no receiver-owned inputs. + /// Check that none of the Original PSBT's inputs belong to the receiver, + /// preventing an attacker from spending the receiver's own inputs. /// - /// An attacker can try to spend the receiver's own inputs. This check prevents that. + /// Returns a [`MaybeInputsSeen`] to continue validation. pub fn check_inputs_not_owned( self, is_owned: &mut impl FnMut(&Script) -> Result, @@ -156,22 +146,27 @@ impl MaybeInputsOwned { } } -/// Typestate to check that the original PSBT has no inputs that the receiver has seen before. +/// Typestate to check that the Original PSBT has no inputs the receiver has seen before. +/// +/// This check prevents the following attacks: +/// 1. Probing attacks, where the sender uses 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 PSBT of the current, new payjoin. /// -/// Call [`Self::check_no_inputs_seen_before`] to proceed. +/// Call [`Self::check_no_inputs_seen_before`] to advance to [`OutputsUnknown`] to +/// continue validation. #[derive(Debug, Clone)] pub struct MaybeInputsSeen { original: OriginalPayload, } impl MaybeInputsSeen { - /// Check that the receiver has never seen the inputs in the original proposal before. + /// Check that none of the inputs have been seen before, preventing input + /// probing and replay attacks (where inputs have been used in a previous + /// payjoin attempt). /// - /// 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. + /// Returns an [`OutputsUnknown`] to continue validation. pub fn check_no_inputs_seen_before( self, is_known: &mut impl FnMut(&OutPoint) -> Result, @@ -181,28 +176,24 @@ impl MaybeInputsSeen { } } -/// Typestate to check that the outputs of the original PSBT actually pay to the receiver. +/// Typestate to check that the outputs of the Original PSBT actually pay the receiver. /// -/// The receiver should only accept the original PSBTs from the sender if it actually sends them -/// money. -/// -/// Call [`Self::identify_receiver_outputs`] to proceed. +/// The receiver should only accept Original PSBTs from the sender that actually send +/// them money. Call [`Self::identify_receiver_outputs`] to advance to [`WantsOutputs`] +/// to continue the proposal. #[derive(Debug, Clone)] pub struct OutputsUnknown { original: OriginalPayload, } impl OutputsUnknown { - /// Validates whether the original PSBT contains outputs which pay to the receiver and only - /// then proceeds to the next typestate. + /// Identify which outputs in the original transaction belong to the receiver + /// and ensure at least one output pays the receiver. + /// + /// If the sender designated a receiver output for fee subtraction, that designation + /// is cleared so the receiver does not accidentally subtract fees from their own output. /// - /// 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. + /// Returns a [`WantsOutputs`] to continue the proposal. #[cfg_attr(not(feature = "v1"), allow(dead_code))] pub fn identify_receiver_outputs( self, @@ -260,6 +251,8 @@ impl crate::receive::common::WantsFeeRange { /// /// The minimum effective fee limit is the highest of the minimum limit set by the sender in /// the original proposal parameters and the limit passed in the `min_fee_rate` parameter. + /// + /// Returns a [`ProvisionalProposal`]. pub fn apply_fee_range( self, min_fee_rate: Option, @@ -271,21 +264,19 @@ impl crate::receive::common::WantsFeeRange { } } -/// Typestate for a checked proposal which had both the outputs and the inputs modified -/// by the receiver. The receiver may sign and finalize the Payjoin proposal which will be sent to -/// the sender for their signature. +/// Typestate for a checked proposal that the receiver has modified the outputs and +/// inputs of, and is ready to be signed and finalized. /// -/// Call [`Self::finalize_proposal`] to return a finalized [`PayjoinProposal`]. +/// Call [`Self::finalize_proposal`] to advance to [`PayjoinProposal`]. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ProvisionalProposal { psbt_context: PsbtContext, } impl ProvisionalProposal { - /// Finalizes the Payjoin proposal into a PSBT which the sender will find acceptable before - /// they sign the transaction and broadcast it to the network. + /// Finalize the proposal by signing the PSBT via the `wallet_process_psbt` callback. /// - /// Finalization consists of signing and finalizing the PSBT using the passed `wallet_process_psbt` signing function. + /// Returns the final [`PayjoinProposal`]. pub fn finalize_proposal( self, wallet_process_psbt: impl Fn(&Psbt) -> Result, @@ -297,23 +288,28 @@ impl ProvisionalProposal { Ok(PayjoinProposal { payjoin_psbt: finalized_psbt }) } - /// The Payjoin proposal PSBT that the receiver needs to sign + /// Extract the PSBT that needs to be signed by the receiver's wallet. /// - /// In some applications the entity that progresses the typestate - /// is different from the entity that has access to the private keys, - /// so the PSBT to sign must be accessible to such implementers. + /// In some applications the entity that progresses the typestate is different from the + /// entity that has access to the private keys, so the PSBT to sign must be accessible to + /// such implementers. + /// + /// Returns the Payjoin proposal [`Psbt`] to be signed. pub fn psbt_to_sign(&self) -> Psbt { self.psbt_context.psbt_to_sign() } } -/// A finalized Payjoin proposal, complete with fees and receiver signatures, that the sender -/// should find acceptable. +/// Typestate for a signed and finalized Payjoin proposal that is to be sent to the +/// sender for them to sign and broadcast. +/// +/// Extract the proposal PSBT with [`Self::psbt`] and respond to the sender's original +/// request with it. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct PayjoinProposal { payjoin_psbt: Psbt, } impl PayjoinProposal { - /// The Payjoin Proposal PSBT. + /// Returns the finalized payjoin proposal PSBT. pub fn psbt(&self) -> &Psbt { &self.payjoin_psbt } }