From 92dfe808be89f5a7d6f093b3b915135e9b815bd7 Mon Sep 17 00:00:00 2001 From: DanGould Date: Thu, 16 Jul 2026 17:11:45 +0800 Subject: [PATCH 1/6] Seal receiver SessionOutcome before 1.0 freeze The persisted SessionEvent format freezes at the payjoin-cli 1.0 database-backwards-compatibility commitment, so reshape the receiver's terminal SessionOutcome while a format break is still free. Change Success(Vec<(ScriptBuf, Witness)>) to Success(Txid). The witness payload was recorded so the receiver could reconstruct the final Payjoin on the non-segwit path, but #1218 removed that path entirely, leaving the enum payload as dead code. The Receiver settlement classifier (#1692) will restore non-segwit detection, which would make the payload live again, but sufficient record of a settlement is the txid of the transaction that settled it rather than the witness list. The wallet that observed the spend already holds the full transaction. Recording Success(Txid) also keeps the terminal variants consistent where the outcome for the transaction we recognize carries the same evidence as the one we do not. Add Other(Txid), mapped to SessionStatus::Failed, for contested outpoints settled by an unrecognized transaction. Nothing produces it yet. The reason to reserve it now is API semver, not data compatibility. 8c92050 deliberately kept SessionOutcome exhaustive. A wildcard match arm has no meaningful semantics for a terminal outcome, and the compile error on a new variant is a feature. So adding a variant after 1.0 would be a semver-breaking change that we want. Naming Other before the freeze makes the settlement classifier additive by construction while keeping the enum exhaustive. (Forward compatibility of the persisted data is explicitly not a goal. See the persist module docs, we want unknown state to error.) Update payjoin-cli to match the new shapes exhaustively. no wildcard arms, per 8c92050 surfacing the settlement txid in the history and cancel output, and pin the sealed JSON encodings of the terminal events in a serialization-shape test. StatusText becomes String rather than &'static str in order to accomodate the txid in format!. --- payjoin-cli/src/app/v2/mod.rs | 38 +++++++++------ payjoin/src/core/receive/v2/mod.rs | 20 ++------ payjoin/src/core/receive/v2/session.rs | 64 ++++++++++++++++++++++---- payjoin/tests/integration.rs | 42 ++++++----------- 4 files changed, 99 insertions(+), 65 deletions(-) diff --git a/payjoin-cli/src/app/v2/mod.rs b/payjoin-cli/src/app/v2/mod.rs index 73247b7e8..9b545f11b 100644 --- a/payjoin-cli/src/app/v2/mod.rs +++ b/payjoin-cli/src/app/v2/mod.rs @@ -73,11 +73,11 @@ pub(crate) struct App { } trait StatusText { - fn status_text(&self) -> &'static str; + fn status_text(&self) -> String; } impl StatusText for SendSession { - fn status_text(&self) -> &'static str { + fn status_text(&self) -> String { match self { SendSession::WithReplyKey(_) | SendSession::PollingForProposal(_) => "Waiting for proposal", @@ -87,13 +87,14 @@ impl StatusText for SendSession { }, SendSession::PendingFallback(_) => "Session awaiting fallback", } + .to_string() } } impl StatusText for ReceiveSession { - fn status_text(&self) -> &'static str { + fn status_text(&self) -> String { match self { - ReceiveSession::Initialized(_) => "Waiting for original proposal", + ReceiveSession::Initialized(_) => "Waiting for original proposal".to_string(), ReceiveSession::UncheckedOriginalPayload(_) | ReceiveSession::MaybeInputsOwned(_) | ReceiveSession::MaybeInputsSeen(_) @@ -101,18 +102,21 @@ impl StatusText for ReceiveSession { | ReceiveSession::WantsOutputs(_) | ReceiveSession::WantsInputs(_) | ReceiveSession::WantsFeeRange(_) - | ReceiveSession::ProvisionalProposal(_) => "Processing original proposal", - ReceiveSession::PayjoinProposal(_) => "Payjoin proposal sent", + | ReceiveSession::ProvisionalProposal(_) => "Processing original proposal".to_string(), + ReceiveSession::PayjoinProposal(_) => "Payjoin proposal sent".to_string(), ReceiveSession::HasReplyableError(_) => - "Session failure, waiting to post error response", - ReceiveSession::Monitor(_) => "Monitoring payjoin proposal", - ReceiveSession::PendingFallback(_) => "Pending fallback handling", + "Session failure, waiting to post error response".to_string(), + ReceiveSession::Monitor(_) => "Monitoring payjoin proposal".to_string(), + ReceiveSession::PendingFallback(_) => "Pending fallback handling".to_string(), ReceiveSession::Closed(session_outcome) => match session_outcome { - ReceiverSessionOutcome::Aborted => "Session aborted", - ReceiverSessionOutcome::Success(_) => "Session success, Payjoin proposal was broadcasted", - ReceiverSessionOutcome::FallbackBroadcasted => "Fallback broadcasted", + ReceiverSessionOutcome::Aborted => "Session aborted".to_string(), + ReceiverSessionOutcome::Success(txid) => + format!("Session success, Payjoin transaction {txid} was broadcasted"), + ReceiverSessionOutcome::FallbackBroadcasted => "Fallback broadcasted".to_string(), ReceiverSessionOutcome::PayjoinProposalSent => - "Payjoin proposal sent, skipping monitoring as the sender is spending non-SegWit inputs", + "Payjoin proposal sent, skipping monitoring as the sender is spending non-SegWit inputs".to_string(), + ReceiverSessionOutcome::Other(txid) => + format!("Settled by an unrecognized transaction {txid}"), }, } } @@ -178,7 +182,7 @@ impl fmt::Display for SessionHistoryRow { (Some(err), _) => err.to_string(), (None, true) => format!("{}, Fallback transaction available", self.status.status_text()), - (None, false) => self.status.status_text().to_string(), + (None, false) => self.status.status_text(), }; write!( f, @@ -765,6 +769,12 @@ impl App { } return Ok(()); } + ReceiveSession::Closed(ReceiverSessionOutcome::Other(txid)) => { + persister.print(format_args!( + "Session was already settled by an unrecognized transaction {txid}. Cannot cancel." + )); + return Ok(()); + } }; if no_broadcast { diff --git a/payjoin/src/core/receive/v2/mod.rs b/payjoin/src/core/receive/v2/mod.rs index 542f5e23c..30e2aeabe 100644 --- a/payjoin/src/core/receive/v2/mod.rs +++ b/payjoin/src/core/receive/v2/mod.rs @@ -1566,17 +1566,10 @@ impl Receiver { self, ); } - // 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. + // Payjoin transaction with SegWit inputs was detected. Complete the session, + // recording the txid of the transaction that settled it. return MaybeFatalOrSuccessTransition::success(SessionEvent::Closed( - SessionOutcome::Success(sender_witnesses), + SessionOutcome::Success(tx_id), )); } Ok(None) => {} @@ -1636,7 +1629,7 @@ pub(crate) fn pj_uri<'a>( pub mod test { use std::str::FromStr; - use bitcoin::{Amount, FeeRate, ScriptBuf, Witness}; + use bitcoin::{Amount, FeeRate}; use once_cell::sync::Lazy; use payjoin_test_utils::{ BoxError, EXAMPLE_URL, ORIGINAL_PSBT, PARSED_ORIGINAL_PSBT, PARSED_PAYJOIN_PROPOSAL, @@ -1774,10 +1767,7 @@ pub mod test { assert_eq!(persister.inner.lock().expect("Shouldn't be poisoned").events.len(), 1); assert_eq!( persister.inner.lock().expect("Shouldn't be poisoned").events.last(), - Some(&SessionEvent::Closed(SessionOutcome::Success(vec![( - ScriptBuf::default(), - Witness::default() - )]))) + Some(&SessionEvent::Closed(SessionOutcome::Success(payjoin_tx.compute_txid()))) ); // Fallback was broadcasted, should progress to success diff --git a/payjoin/src/core/receive/v2/session.rs b/payjoin/src/core/receive/v2/session.rs index ba0670e2a..7db2088a9 100644 --- a/payjoin/src/core/receive/v2/session.rs +++ b/payjoin/src/core/receive/v2/session.rs @@ -166,7 +166,8 @@ impl SessionHistory { Some(SessionEvent::Closed(outcome)) => match outcome { SessionOutcome::Success(_) | SessionOutcome::PayjoinProposalSent => SessionStatus::Completed, - SessionOutcome::Aborted => SessionStatus::Failed, + // Other: the Payjoin did not happen and it was not a clean fallback either. + SessionOutcome::Aborted | SessionOutcome::Other(_) => SessionStatus::Failed, SessionOutcome::FallbackBroadcasted => SessionStatus::FallbackBroadcasted, }, Some(SessionEvent::Cancelled | SessionEvent::ProtocolFailed) => @@ -212,10 +213,14 @@ pub enum SessionEvent { } /// Represents all possible outcomes for a closed Payjoin session +/// +/// This enum is deliberately exhaustive: a terminal outcome is a closed set, and a wildcard arm +/// has no meaningful semantics for a caller deciding what a session's conclusion was. The +/// compile error on a new variant is the feature. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub enum SessionOutcome { - /// Payjoin completed successfully - Success(Vec<(bitcoin::ScriptBuf, bitcoin::Witness)>), + /// Payjoin completed successfully: the transaction with this txid settled the session + Success(bitcoin::Txid), /// Payjoin was not successful Aborted, /// Fallback transaction was broadcasted @@ -224,6 +229,13 @@ pub enum SessionOutcome { /// the sender is using non-SegWit inputs which will change the transaction ID /// of the proposal PayjoinProposalSent, + /// The contested outpoints were settled by an unrecognized transaction (neither the + /// Payjoin nor the fallback), identified by its txid. + /// + /// Nothing in this release produces this variant. It is reserved now because this enum is + /// deliberately exhaustive, so a new variant after 1.0 would be a semver-breaking API + /// change; naming it before the freeze lets the settlement classifier land additively. + Other(bitcoin::Txid), } #[cfg(test)] @@ -564,6 +576,11 @@ mod tests { SessionEvent::GotReplyableError(mock_err()), SessionEvent::Cancelled, SessionEvent::ProtocolFailed, + SessionEvent::Closed(SessionOutcome::Success(bitcoin::Txid::all_zeros())), + SessionEvent::Closed(SessionOutcome::Aborted), + SessionEvent::Closed(SessionOutcome::FallbackBroadcasted), + SessionEvent::Closed(SessionOutcome::PayjoinProposalSent), + SessionEvent::Closed(SessionOutcome::Other(bitcoin::Txid::all_zeros())), ]; for event in test_cases { @@ -671,7 +688,7 @@ mod tests { let success = SessionHistory::new(vec![ SessionEvent::Created(session_context.clone()), - SessionEvent::Closed(SessionOutcome::Success(vec![])), + SessionEvent::Closed(SessionOutcome::Success(bitcoin::Txid::all_zeros())), ]); assert_eq!(success.status(), SessionStatus::Completed); @@ -687,11 +704,40 @@ mod tests { ]); assert_eq!(fallback.status(), SessionStatus::FallbackBroadcasted); + let other = SessionHistory::new(vec![ + SessionEvent::Created(session_context.clone()), + SessionEvent::Closed(SessionOutcome::Other(bitcoin::Txid::all_zeros())), + ]); + assert_eq!(other.status(), SessionStatus::Failed); + // Sessions that never reached a terminal state still report Expired. let still_open = SessionHistory::new(vec![SessionEvent::Created(session_context)]); assert_eq!(still_open.status(), SessionStatus::Expired); } + // The persisted encoding of terminal outcomes freezes at 1.0: these exact strings are + // what a 1.0 binary must keep parsing. + #[test] + fn closed_event_serialization_shape_is_sealed() { + let success = serde_json::to_string(&SessionEvent::Closed(SessionOutcome::Success( + bitcoin::Txid::all_zeros(), + ))) + .expect("Serialization should not fail"); + assert_eq!( + success, + r#"{"Closed":{"Success":"0000000000000000000000000000000000000000000000000000000000000000"}}"# + ); + + let other = serde_json::to_string(&SessionEvent::Closed(SessionOutcome::Other( + bitcoin::Txid::all_zeros(), + ))) + .expect("Serialization should not fail"); + assert_eq!( + other, + r#"{"Closed":{"Other":"0000000000000000000000000000000000000000000000000000000000000000"}}"# + ); + } + #[tokio::test] async fn test_replaying_closed_session_past_expiration_is_not_expired() { let expiration = (SystemTime::now() - Duration::from_secs(1)).try_into().unwrap(); @@ -702,7 +748,7 @@ mod tests { .save_event(SessionEvent::Created(session_context.clone())) .expect("in memory persister save should not fail"); persister - .save_event(SessionEvent::Closed(SessionOutcome::Success(vec![]))) + .save_event(SessionEvent::Closed(SessionOutcome::Success(bitcoin::Txid::all_zeros()))) .expect("in memory persister save should not fail"); let (state, _) = replay_event_log(&persister).expect("closed session should replay successfully"); @@ -714,7 +760,7 @@ mod tests { .await .expect("in memory async persister save should not fail"); persister - .save_event(SessionEvent::Closed(SessionOutcome::Success(vec![]))) + .save_event(SessionEvent::Closed(SessionOutcome::Success(bitcoin::Txid::all_zeros()))) .await .expect("in memory async persister save should not fail"); let (state, _) = replay_event_log_async(&persister) @@ -1085,7 +1131,7 @@ mod tests { }); events.push(SessionEvent::AppliedFeeRange(provisional_proposal.state.psbt_context.clone())); events.push(SessionEvent::FinalizedProposal(payjoin_proposal.psbt().clone())); - events.push(SessionEvent::Closed(SessionOutcome::Success(vec![]))); + events.push(SessionEvent::Closed(SessionOutcome::Success(bitcoin::Txid::all_zeros()))); let test = SessionHistoryTest { events, @@ -1093,7 +1139,9 @@ mod tests { fallback_tx: Some(expected_fallback), expected_status: SessionStatus::Completed, }, - expected_receiver_state: ReceiveSession::Closed(SessionOutcome::Success(vec![])), + expected_receiver_state: ReceiveSession::Closed(SessionOutcome::Success( + bitcoin::Txid::all_zeros(), + )), }; run_session_history_test(&test); run_session_history_test_async(&test).await; diff --git a/payjoin/tests/integration.rs b/payjoin/tests/integration.rs index 6bf8dc15b..18f5b1447 100644 --- a/payjoin/tests/integration.rs +++ b/payjoin/tests/integration.rs @@ -625,23 +625,16 @@ mod integration { .save(&recv_persister) .expect("receiver should successfully monitor for the payment"); - // Receiver session should have completed with a Success, along with information on the - // sender signatures on the Payjoin that was broadcasted. + // Receiver session should have completed with a Success. let (_session, session_history) = replay_receiver_event_log(&recv_persister)?; - let sender_outpoint = session_history.fallback_tx().unwrap().input[0].previous_output; - let sender_signatures = { - let sender_txin = broadcasted_transaction - .input - .iter() - .find(|txin| txin.previous_output == sender_outpoint) - .expect("sender input must be present in payjoin_tx") - .clone(); - vec![(sender_txin.clone().script_sig, sender_txin.clone().witness)] - }; assert_eq!( recv_persister.load().unwrap().last(), - Some(payjoin::receive::v2::SessionEvent::Closed(payjoin::receive::v2::SessionOutcome::Success(sender_signatures))), - "The last event of the persister should be a SessionOutcome::Success with the correct sender signature", + Some(payjoin::receive::v2::SessionEvent::Closed( + payjoin::receive::v2::SessionOutcome::Success( + broadcasted_transaction.compute_txid() + ) + )), + "The last event of the persister should be a SessionOutcome::Success with the settlement txid", ); assert_eq!(session_history.status(), SessionStatus::Completed); Ok(()) @@ -707,23 +700,16 @@ mod integration { .save(&recv_persister) .expect("receiver should successfully monitor for the payment"); - // Receiver session should have completed with a Success, along with information on the - // sender signatures on the Payjoin that was broadcasted. + // Receiver session should have completed with a Success. let (_session, session_history) = replay_receiver_event_log(&recv_persister)?; - let sender_outpoint = session_history.fallback_tx().unwrap().input[0].previous_output; - let sender_signatures = { - let sender_txin = broadcasted_transaction - .input - .iter() - .find(|txin| txin.previous_output == sender_outpoint) - .expect("sender input must be present in payjoin_tx") - .clone(); - vec![(sender_txin.clone().script_sig, sender_txin.clone().witness)] - }; assert_eq!( recv_persister.load().unwrap().last(), - Some(payjoin::receive::v2::SessionEvent::Closed(payjoin::receive::v2::SessionOutcome::Success(sender_signatures))), - "The last event of the persister should be a SessionOutcome::Success with the correct sender signature", + Some(payjoin::receive::v2::SessionEvent::Closed( + payjoin::receive::v2::SessionOutcome::Success( + broadcasted_transaction.compute_txid() + ) + )), + "The last event of the persister should be a SessionOutcome::Success with the settlement txid", ); assert_eq!(session_history.status(), SessionStatus::Completed); Ok(()) From 9afb1cb6a5daef32f1d0cbc4c6c50d2ba0ea26a1 Mon Sep 17 00:00:00 2001 From: DanGould Date: Mon, 29 Jun 2026 17:06:37 +0800 Subject: [PATCH 2/6] Add SettlementStatus and classify for Monitor Introduce outpoint-based settlement attribution for a monitored session. A pure, total classify maps caller-supplied chain facts to a settlement status without any I/O or persisted events. Add the compute types SettlementStatus and SettlementOutcome {Cooperative(Txid), Fallback, Other(Txid)} plus ChainView/OutpointSpend, and the primitive getters the wallet needs (payjoin_txid, fallback_txid, contested_outpoints, receiver_input). classify recognizes a cooperative settlement by either signal: the spending transaction carries the receiver's contributed input (the input fingerprint) or, for an output-substitution proposal, reproduces the proposal's output set (a cut-through). Both are structural, so a non-SegWit txid change does not hide them; Cooperative therefore carries the observed settling txid, which in that case differs from the predicted payjoin_txid and is otherwise unavailable to the caller of a txid-oblivious classifier. Precedence is Cooperative > Fallback > Other. SettlementOutcome is #[non_exhaustive]. This is a deliberate exception to 8c92050's keep-exhaustive list, judged by that commit's own test: does a wildcard arm have meaningful semantics? For a *persisted* terminal outcome it does not (SessionOutcome stays exhaustive). But SettlementOutcome is a never-persisted classification verdict, and a finer future label (say, distinguishing a sender re-spend from a third-party spend) degrades soundly in a wildcard arm to "settled by something this build cannot label -- surface for review". No session state is resumed through it, so the failure mode 8c92050 guards against does not arise. SettlementStatus itself stays exhaustive: Pending/Detected is a closed shape. --- payjoin/src/core/receive/v2/mod.rs | 685 ++++++++++++++++++++++++++++- 1 file changed, 683 insertions(+), 2 deletions(-) diff --git a/payjoin/src/core/receive/v2/mod.rs b/payjoin/src/core/receive/v2/mod.rs index 30e2aeabe..a27995fb4 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, Transaction, TxOut, Txid}; pub use error::{CreateRequestError, SessionError}; pub(crate) use error::{InternalCreateRequestError, InternalSessionError}; use serde::de::Deserializer; @@ -1514,6 +1514,107 @@ pub struct Monitor { psbt_context: PsbtContext, } +/// Compare two transaction output sets for equality, order-independently. +/// +/// Cut-through (output-substitution) detection matches a spending transaction's outputs against the +/// proposal's. BIP69 (or any wallet) may order outputs differently, so the comparison is a multiset +/// equality rather than a positional one. +fn output_sets_match(a: &[TxOut], b: &[TxOut]) -> bool { + if a.len() != b.len() { + return false; + } + let key = |out: &&TxOut| (out.value, out.script_pubkey.as_bytes().to_vec()); + let mut a: Vec<&TxOut> = a.iter().collect(); + let mut b: Vec<&TxOut> = b.iter().collect(); + a.sort_by_key(key); + b.sort_by_key(key); + a == b +} + +/// The settlement state of a monitored Payjoin session, derived from live chain data. +/// +/// `SettlementStatus` is **detection, not settlement**: it reports what the caller's +/// [`ChainView`] currently shows, is recomputed on every [`Receiver::classify`] call, +/// and is never persisted. The proposal and the fallback double-spend the sender's inputs, so a +/// reorg or the conflicting transaction can still flip a `Detected` verdict; the caller decides +/// when the `Detected` confirmation count meets its confidence bar. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SettlementStatus { + /// None of the session's contested outpoints are spent yet. + Pending, + /// A transaction spending the contested outpoints was observed. + /// + /// `confirmations == 0` means the spend is currently only seen in the mempool. + Detected { + /// What kind of transaction settled the contested outpoints. + outcome: SettlementOutcome, + /// The minimum confirmation count over the spends that support `outcome`. + /// `0` means mempool-only. + confirmations: u32, + }, +} + +/// What kind of transaction settled a monitored session's contested outpoints. +/// +/// The distinction that matters is **cooperative vs not**: did the receiver's contribution — a +/// contributed input and/or an output substitution — get accepted and settle? Whether a +/// cooperative settlement was input-contributed or a cut-through is a static property of the +/// *proposal*, derivable from the session log and unrelated to which transaction won the race, so +/// it is deliberately not modeled here. +/// +/// Outcome precedence is **Cooperative > Fallback > Other** (see [`Receiver::classify`]). +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum SettlementOutcome { + /// The Payjoin settled cooperatively: the spending transaction contains the receiver's + /// contributed input (the payjoin fingerprint) or, for an output-substitution proposal, + /// reproduces the proposal's output set (a cut-through). Detection is structural, not + /// txid-based, so it holds even when the on-chain txid differs from the predicted Payjoin + /// txid (a non-SegWit sender); the carried txid is the observed settling transaction's. + Cooperative(Txid), + /// The fallback (original) transaction settled. The payment went through but the privacy gain + /// of the Payjoin was lost. + Fallback, + /// The contested outpoints were spent by an unrecognized transaction (neither the Payjoin nor + /// the fallback) — for example the sender re-spending its inputs elsewhere. + Other(Txid), +} + +/// A single contested outpoint's spend status, as observed by the caller's chain backend. +/// +/// The wallet (not the library) is the oracle for which outpoint is spent by what, at what depth. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OutpointSpend { + /// The contested outpoint this entry describes. + pub outpoint: OutPoint, + /// The spend of `outpoint`, or `None` if it is unspent / unknown. + pub spend: Option, +} + +/// A spend of a contested outpoint: the full spending transaction and its confirmation depth. +/// +/// The transaction is mandatory: structural classification needs the body, and the spending txid +/// is derived from it rather than carried alongside, so the two can never disagree. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Spend { + /// The full transaction that spent the outpoint. + pub tx: Transaction, + /// Confirmations of the spend. `0` means mempool-only. + pub confirmations: u32, +} + +/// Caller-supplied snapshot of chain facts about a monitored session's contested outpoints. +/// +/// This is the input to the pure [`Receiver::classify`] classifier. Chain access, poll +/// cadence, and the confidence threshold all stay in the caller; the library only interprets the +/// snapshot. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ChainView { + /// Observed spend status for (a subset of) the contested outpoints. Entries for outpoints that + /// are not contested are ignored. + pub spends: Vec, +} + /// Typestate to monitor the network for the Payjoin proposal or fallback transaction. /// /// After the Payjoin proposal is signed and sent back to the sender, the receiver should monitor @@ -1525,6 +1626,159 @@ 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 { + /// The txid of the Payjoin proposal as the receiver built it. + /// + /// For SegWit-only senders this is the txid that will appear on chain. For senders spending + /// non-SegWit inputs the on-chain txid will differ once they sign, which is why + /// [`Receiver::classify`] matches structurally rather than by this txid. + pub fn payjoin_txid(&self) -> Txid { + self.state.psbt_context.payjoin_psbt.unsigned_tx.compute_txid() + } + + /// The txid of the fallback (original) transaction the sender can broadcast instead. + /// + /// Always `Some` for a `Monitor` session; the `Option` mirrors the rest of the API where a + /// fallback may be absent. + pub fn fallback_txid(&self) -> Option { Some(self.state.fallback_tx().compute_txid()) } + + /// The sender's original inputs — the outpoints the Payjoin proposal and the fallback both + /// contend to spend; only one transaction can ever spend them. + pub fn contested_outpoints(&self) -> Vec { + self.state + .psbt_context + .original_psbt + .unsigned_tx + .input + .iter() + .map(|txin| txin.previous_output) + .collect() + } + + /// The receiver's contributed input — present in the Payjoin proposal but not in the sender's + /// original transaction. Its presence in a spending transaction is the structural payjoin + /// fingerprint. + /// + /// Returns `None` for an output-substitution-only Payjoin, where the receiver substituted + /// outputs but contributed no input. Such a proposal has no input fingerprint, so + /// [`Receiver::classify`] cannot use the structural Payjoin step for it. + pub fn receiver_input(&self) -> Option { + let contested = self.contested_outpoints(); + self.state + .psbt_context + .payjoin_psbt + .unsigned_tx + .input + .iter() + .map(|txin| txin.previous_output) + .find(|outpoint| !contested.contains(outpoint)) + } + + /// The Payjoin proposal's outputs — the fingerprint for cut-through (output-substitution) + /// detection, since such a proposal carries no input fingerprint. + fn proposal_outputs(&self) -> &[TxOut] { + &self.state.psbt_context.payjoin_psbt.unsigned_tx.output + } + + /// Whether the proposal substitutes the sender's original outputs. + /// + /// Output-substitution (cut-through) detection is only meaningful when the proposal's output + /// set actually differs from the original/fallback output set. If they are equal, the proposal + /// and the fallback are the same transaction, so a matching spend is the fallback, not a + /// cut-through. + fn proposal_substitutes_outputs(&self) -> bool { + let original = &self.state.psbt_context.original_psbt.unsigned_tx.output; + !output_sets_match(self.proposal_outputs(), original) + } + + /// Classify the current settlement state from a caller-supplied [`ChainView`]. + /// + /// This is a **pure** function: it performs no I/O, mutates nothing, and persists nothing. The + /// caller polls the chain, builds a [`ChainView`], and calls `classify` as often as it likes. + /// + /// Classification is structural, not txid-based. The outcome precedence is + /// **Cooperative > Fallback > Other**: + /// 1. No contested outpoint is spent → [`SettlementStatus::Pending`]. + /// 2. A spend carries either cooperative signal → [`SettlementOutcome::Cooperative`]: it + /// contains the receiver's contributed input ([`Receiver::receiver_input`], absent + /// for an output-substitution-only proposal), or its output set matches the proposal's + /// order-independently, gated on the proposal actually substituting the original outputs. + /// Both signals survive the non-SegWit case where the on-chain txid differs from + /// [`Receiver::payjoin_txid`]; the carried txid is the settling transaction's + /// observed txid, which is why it is reported rather than the prediction. + /// 3. Otherwise, a contested outpoint spent by [`Receiver::fallback_txid`] → + /// [`SettlementOutcome::Fallback`]. + /// 4. Otherwise → [`SettlementOutcome::Other`] carrying the unrecognized spending txid. + /// + /// If contested outpoints are split across multiple transactions, the precedence above + /// decides: Cooperative if *any* spend matches either signal, else Fallback if *any* spend is + /// by the fallback txid, else the first unrecognized spend determines `Other`. The reported + /// `confirmations` is the minimum over the spends supporting the chosen outcome. + pub fn classify(&self, view: &ChainView) -> SettlementStatus { + let contested = self.contested_outpoints(); + + let relevant: Vec<&Spend> = view + .spends + .iter() + .filter(|os| contested.contains(&os.outpoint)) + .filter_map(|os| os.spend.as_ref()) + .collect(); + + if relevant.is_empty() { + return SettlementStatus::Pending; + } + + let min_confirmations = |spends: &[&Spend]| -> u32 { + spends.iter().map(|spend| spend.confirmations).min().unwrap_or(0) + }; + + // 2. Cooperative: either structural signal — the input fingerprint or the output-set match. + let receiver_input = self.receiver_input(); + let substitutes_outputs = self.proposal_substitutes_outputs(); + let proposal_outputs = self.proposal_outputs(); + let cooperative_spends: Vec<&Spend> = relevant + .iter() + .copied() + .filter(|spend| { + let tx = &spend.tx; + let input_fingerprint = receiver_input + .is_some_and(|ri| tx.input.iter().any(|txin| txin.previous_output == ri)); + let output_substitution = + substitutes_outputs && output_sets_match(&tx.output, proposal_outputs); + input_fingerprint || output_substitution + }) + .collect(); + if !cooperative_spends.is_empty() { + return SettlementStatus::Detected { + outcome: SettlementOutcome::Cooperative(cooperative_spends[0].tx.compute_txid()), + confirmations: min_confirmations(&cooperative_spends), + }; + } + + // 3. Fallback: any spend by the fallback txid. + if let Some(fallback_txid) = self.fallback_txid() { + let fallback_spends: Vec<&Spend> = relevant + .iter() + .copied() + .filter(|spend| spend.tx.compute_txid() == fallback_txid) + .collect(); + if !fallback_spends.is_empty() { + return SettlementStatus::Detected { + outcome: SettlementOutcome::Fallback, + confirmations: min_confirmations(&fallback_spends), + }; + } + } + + // 4. Other: spent by an unrecognized transaction. + let txid = relevant[0].tx.compute_txid(); + let other_spends: Vec<&Spend> = + relevant.iter().copied().filter(|spend| spend.tx.compute_txid() == txid).collect(); + SettlementStatus::Detected { + outcome: SettlementOutcome::Other(txid), + confirmations: min_confirmations(&other_spends), + } + } + /// 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, @@ -1629,7 +1883,7 @@ pub(crate) fn pj_uri<'a>( pub mod test { use std::str::FromStr; - use bitcoin::{Amount, FeeRate}; + use bitcoin::{Amount, FeeRate, ScriptBuf}; use once_cell::sync::Lazy; use payjoin_test_utils::{ BoxError, EXAMPLE_URL, ORIGINAL_PSBT, PARSED_ORIGINAL_PSBT, PARSED_PAYJOIN_PROPOSAL, @@ -1827,6 +2081,433 @@ pub mod test { Ok(()) } + fn segwit_monitor() -> Receiver { + let psbt_ctx = PsbtContext { + original_psbt: PARSED_ORIGINAL_PSBT.clone(), + payjoin_psbt: PARSED_PAYJOIN_PROPOSAL.clone(), + }; + Receiver { + state: Monitor { psbt_context: psbt_ctx }, + session_context: SHARED_CONTEXT.clone(), + } + } + + /// A monitor whose proposal contributes no receiver input and substitutes no outputs: the + /// proposal is identical to the sender's original transaction. `receiver_input` is `None`, so + /// `classify` must stay total and fall through to Fallback/Other without panicking. + fn no_receiver_input_monitor() -> Receiver { + let original = PARSED_ORIGINAL_PSBT.clone(); + let psbt_ctx = PsbtContext { original_psbt: original.clone(), payjoin_psbt: original }; + Receiver { + state: Monitor { psbt_context: psbt_ctx }, + session_context: SHARED_CONTEXT.clone(), + } + } + + /// A monitor whose proposal substitutes outputs but contributes no input (output-substitution / + /// cut-through Payjoin): same inputs as the sender's original, a different output set. + fn cutthrough_monitor() -> Receiver { + let original = PARSED_ORIGINAL_PSBT.clone(); + let mut proposal = original.clone(); + // Substitute the receiver's output script so the proposal's output set differs from the + // original's, while keeping the sender's inputs (no receiver-contributed input). + proposal.unsigned_tx.output[0].script_pubkey = + ScriptBuf::from_bytes(vec![0x6a, 0x01, 0x2a]); + let psbt_ctx = PsbtContext { original_psbt: original, payjoin_psbt: proposal }; + Receiver { + state: Monitor { psbt_context: psbt_ctx }, + session_context: SHARED_CONTEXT.clone(), + } + } + + /// The proposal transaction a [`cutthrough_monitor`] is built from. + fn cutthrough_proposal_tx(monitor: &Receiver) -> Transaction { + monitor.state.psbt_context.payjoin_psbt.unsigned_tx.clone() + } + + /// Return a clone of `tx` with a different txid (a non-empty script_sig on the first input), + /// simulating a non-SegWit signature changing the txid without changing the input set. + fn with_perturbed_txid(mut tx: Transaction) -> Transaction { + tx.input[0].script_sig = ScriptBuf::from_bytes(vec![0x51]); // OP_TRUE, arbitrary + tx + } + + fn spend(outpoint: OutPoint, tx: Transaction, confirmations: u32) -> OutpointSpend { + OutpointSpend { outpoint, spend: Some(Spend { tx, confirmations }) } + } + + #[test] + fn test_monitor_primitive_getters() { + let monitor = segwit_monitor(); + let payjoin_tx = PARSED_PAYJOIN_PROPOSAL.clone().unsigned_tx; + let original_tx = PARSED_ORIGINAL_PSBT.clone().extract_tx().expect("valid tx"); + + assert_eq!(monitor.payjoin_txid(), payjoin_tx.compute_txid()); + assert_eq!(monitor.fallback_txid(), Some(original_tx.compute_txid())); + + let contested = monitor.contested_outpoints(); + let expected_contested: Vec = + original_tx.input.iter().map(|txin| txin.previous_output).collect(); + assert_eq!(contested, expected_contested); + + // The receiver input is present in the proposal but not in the sender's original. + let receiver_input = + monitor.receiver_input().expect("segwit proposal contributes a receiver input"); + assert!(!contested.contains(&receiver_input)); + assert!(payjoin_tx.input.iter().any(|txin| txin.previous_output == receiver_input)); + } + + #[test] + fn test_classify_pending() { + let monitor = segwit_monitor(); + + // Empty view → pending. + assert_eq!(monitor.classify(&ChainView::default()), SettlementStatus::Pending); + + // Contested outpoints reported but unspent → pending. + let view = ChainView { + spends: monitor + .contested_outpoints() + .into_iter() + .map(|outpoint| OutpointSpend { outpoint, spend: None }) + .collect(), + }; + assert_eq!(monitor.classify(&view), SettlementStatus::Pending); + } + + #[test] + fn test_classify_no_receiver_input_is_total() { + let monitor = no_receiver_input_monitor(); + assert!(monitor.receiver_input().is_none()); + + // Empty view → Pending, no panic. + assert_eq!(monitor.classify(&ChainView::default()), SettlementStatus::Pending); + + // Reported but unspent → Pending, no panic. + let unspent = ChainView { + spends: monitor + .contested_outpoints() + .into_iter() + .map(|outpoint| OutpointSpend { outpoint, spend: None }) + .collect(), + }; + assert_eq!(monitor.classify(&unspent), SettlementStatus::Pending); + } + + #[test] + fn test_classify_fallback_without_receiver_input() { + // The structural Payjoin step is skipped (no receiver input), but a spend by the fallback + // txid is still detected as Fallback. + let monitor = no_receiver_input_monitor(); + let fallback_tx = PARSED_ORIGINAL_PSBT.clone().extract_tx().expect("valid tx"); + + let view = ChainView { + spends: monitor + .contested_outpoints() + .into_iter() + .map(|outpoint| spend(outpoint, fallback_tx.clone(), 4)) + .collect(), + }; + + assert_eq!( + monitor.classify(&view), + SettlementStatus::Detected { outcome: SettlementOutcome::Fallback, confirmations: 4 } + ); + } + + #[test] + fn test_classify_other_without_receiver_input() { + // No receiver input and no recognized txid: an unrelated spend still resolves to Other + // rather than panicking. + let monitor = no_receiver_input_monitor(); + let other_tx = with_perturbed_txid(PARSED_ORIGINAL_PSBT.clone().extract_tx().expect("tx")); + let other_txid = other_tx.compute_txid(); + assert_ne!(Some(other_txid), monitor.fallback_txid()); + + let view = ChainView { + spends: monitor + .contested_outpoints() + .into_iter() + .map(|outpoint| spend(outpoint, other_tx.clone(), 6)) + .collect(), + }; + + assert_eq!( + monitor.classify(&view), + SettlementStatus::Detected { + outcome: SettlementOutcome::Other(other_txid), + confirmations: 6, + } + ); + } + + #[test] + fn test_classify_cooperative_input_fingerprint() { + let monitor = segwit_monitor(); + let payjoin_tx = PARSED_PAYJOIN_PROPOSAL.clone().unsigned_tx; + + let view = ChainView { + spends: monitor + .contested_outpoints() + .into_iter() + .map(|outpoint| spend(outpoint, payjoin_tx.clone(), 3)) + .collect(), + }; + + assert_eq!( + monitor.classify(&view), + SettlementStatus::Detected { + outcome: SettlementOutcome::Cooperative(payjoin_tx.compute_txid()), + confirmations: 3, + } + ); + } + + #[test] + fn test_classify_cooperative_non_segwit_salvage() { + // The on-chain spending tx contains the receiver's input but has a txid that differs from + // the predicted payjoin txid (as happens when a non-SegWit sender signs). Classification is + // structural, so this is still Cooperative. + let monitor = segwit_monitor(); + let payjoin_tx = PARSED_PAYJOIN_PROPOSAL.clone().unsigned_tx; + let salvaged_tx = with_perturbed_txid(payjoin_tx.clone()); + let salvaged_txid = salvaged_tx.compute_txid(); + assert_ne!(salvaged_txid, monitor.payjoin_txid()); + + let view = ChainView { + spends: monitor + .contested_outpoints() + .into_iter() + .map(|outpoint| spend(outpoint, salvaged_tx.clone(), 1)) + .collect(), + }; + + assert_eq!( + monitor.classify(&view), + SettlementStatus::Detected { + outcome: SettlementOutcome::Cooperative(salvaged_txid), + confirmations: 1, + } + ); + } + + #[test] + fn test_classify_fallback() { + let monitor = segwit_monitor(); + let fallback_tx = PARSED_ORIGINAL_PSBT.clone().extract_tx().expect("valid tx"); + + let view = ChainView { + spends: monitor + .contested_outpoints() + .into_iter() + .map(|outpoint| spend(outpoint, fallback_tx.clone(), 2)) + .collect(), + }; + + assert_eq!( + monitor.classify(&view), + SettlementStatus::Detected { outcome: SettlementOutcome::Fallback, confirmations: 2 } + ); + } + + #[test] + fn test_classify_other() { + // The contested outpoints are spent by a transaction that is neither the Payjoin (no + // receiver input) nor the fallback (different txid). + let monitor = segwit_monitor(); + let fallback_tx = PARSED_ORIGINAL_PSBT.clone().extract_tx().expect("valid tx"); + let other_tx = with_perturbed_txid(fallback_tx); + let other_txid = other_tx.compute_txid(); + assert_ne!(Some(other_txid), monitor.fallback_txid()); + + let view = ChainView { + spends: monitor + .contested_outpoints() + .into_iter() + .map(|outpoint| spend(outpoint, other_tx.clone(), 5)) + .collect(), + }; + + assert_eq!( + monitor.classify(&view), + SettlementStatus::Detected { + outcome: SettlementOutcome::Other(other_txid), + confirmations: 5, + } + ); + } + + #[test] + fn test_classify_cooperative_output_substitution() { + // The receiver substituted outputs but contributed no input. The spending tx carries no + // input fingerprint, but its output set matches the proposal's, so it is Cooperative + // (cut-through). + let monitor = cutthrough_monitor(); + assert!(monitor.receiver_input().is_none()); + + let proposal_tx = cutthrough_proposal_tx(&monitor); + let view = ChainView { + spends: monitor + .contested_outpoints() + .into_iter() + .map(|outpoint| spend(outpoint, proposal_tx.clone(), 3)) + .collect(), + }; + + assert_eq!( + monitor.classify(&view), + SettlementStatus::Detected { + outcome: SettlementOutcome::Cooperative(proposal_tx.compute_txid()), + confirmations: 3, + } + ); + } + + #[test] + fn test_classify_cooperative_output_sub_non_segwit_salvage() { + // Output sets are stable under non-SegWit signing, so an output-substitution cooperative + // settlement is detected even when the on-chain txid differs from anything the receiver + // predicted. + let monitor = cutthrough_monitor(); + let salvaged_tx = with_perturbed_txid(cutthrough_proposal_tx(&monitor)); + let salvaged_txid = salvaged_tx.compute_txid(); + assert_ne!(salvaged_txid, monitor.payjoin_txid()); + assert_ne!(Some(salvaged_txid), monitor.fallback_txid()); + + let view = ChainView { + spends: monitor + .contested_outpoints() + .into_iter() + .map(|outpoint| spend(outpoint, salvaged_tx.clone(), 1)) + .collect(), + }; + + assert_eq!( + monitor.classify(&view), + SettlementStatus::Detected { + outcome: SettlementOutcome::Cooperative(salvaged_txid), + confirmations: 1, + } + ); + } + + #[test] + fn test_classify_cooperative_output_order_independent() { + // BIP69 (or any wallet) may reorder outputs; the output-set match must be order-independent. + let monitor = cutthrough_monitor(); + let mut reordered_tx = cutthrough_proposal_tx(&monitor); + reordered_tx.output.reverse(); + + let view = ChainView { + spends: monitor + .contested_outpoints() + .into_iter() + .map(|outpoint| spend(outpoint, reordered_tx.clone(), 2)) + .collect(), + }; + + assert!(matches!( + monitor.classify(&view), + SettlementStatus::Detected { outcome: SettlementOutcome::Cooperative(_), .. } + )); + } + + #[test] + fn test_classify_no_substitution_is_fallback_not_cooperative() { + // A proposal that neither adds an input nor substitutes outputs is identical to the + // fallback. A spend by that transaction is Fallback; the substitution guard keeps the + // output-set match from stealing it as Cooperative. + let monitor = no_receiver_input_monitor(); + assert!(monitor.receiver_input().is_none()); + let fallback_tx = PARSED_ORIGINAL_PSBT.clone().extract_tx().expect("valid tx"); + + let view = ChainView { + spends: monitor + .contested_outpoints() + .into_iter() + .map(|outpoint| spend(outpoint, fallback_tx.clone(), 2)) + .collect(), + }; + + assert_eq!( + monitor.classify(&view), + SettlementStatus::Detected { outcome: SettlementOutcome::Fallback, confirmations: 2 } + ); + } + + #[test] + fn test_classify_cooperative_via_both_signals() { + // The segwit proposal both contributes a receiver input and substitutes the receiver's + // output, so a spend satisfies both cooperative signals at once. Either alone is enough; the + // result is Cooperative. + let monitor = segwit_monitor(); + assert!(monitor.receiver_input().is_some()); + let payjoin_tx = PARSED_PAYJOIN_PROPOSAL.clone().unsigned_tx; + assert!(monitor.proposal_substitutes_outputs()); + assert!(output_sets_match(&payjoin_tx.output, monitor.proposal_outputs())); + + let view = ChainView { + spends: monitor + .contested_outpoints() + .into_iter() + .map(|outpoint| spend(outpoint, payjoin_tx.clone(), 3)) + .collect(), + }; + + assert!(matches!( + monitor.classify(&view), + SettlementStatus::Detected { outcome: SettlementOutcome::Cooperative(_), .. } + )); + } + + #[test] + fn test_classify_confirmations_are_conservative() { + // When the same payjoin tx spends several contested outpoints at different observed depths, + // classify reports the minimum confirmation count. + let monitor = segwit_monitor(); + let payjoin_tx = PARSED_PAYJOIN_PROPOSAL.clone().unsigned_tx; + + let mut confs = (1u32..).step_by(2); // 1, 3, 5, ... + let view = ChainView { + spends: monitor + .contested_outpoints() + .into_iter() + .map(|outpoint| spend(outpoint, payjoin_tx.clone(), confs.next().unwrap())) + .collect(), + }; + + match monitor.classify(&view) { + SettlementStatus::Detected { + outcome: SettlementOutcome::Cooperative(_), + confirmations, + } => assert_eq!(confirmations, 1, "min over contested-outpoint spends"), + other => panic!("expected Cooperative, got {other:?}"), + } + } + + #[test] + fn test_classify_ignores_non_contested_spends() { + // A spend of an outpoint that is not one of the session's contested outpoints must not + // influence the verdict. Here the payjoin settles the (single) contested outpoint while an + // unrelated outpoint is spent by some other tx; the result is still Cooperative. + let monitor = segwit_monitor(); + let contested = monitor.contested_outpoints(); + let unrelated_outpoint = OutPoint { txid: Txid::all_zeros(), vout: 7 }; + + let payjoin_tx = PARSED_PAYJOIN_PROPOSAL.clone().unsigned_tx; + let unrelated_tx = with_perturbed_txid(payjoin_tx.clone()); + + let spends = vec![ + spend(contested[0], payjoin_tx.clone(), 4), + spend(unrelated_outpoint, unrelated_tx, 0), + ]; + + assert!(matches!( + monitor.classify(&ChainView { spends }), + SettlementStatus::Detected { outcome: SettlementOutcome::Cooperative(_), .. } + )); + } + #[test] fn test_v2_mutable_receiver_state_closures() { let persister = InMemoryPersister::default(); From 3e2b1ff3e6d58bfc16ec84f6d217d98c8c33f670 Mon Sep 17 00:00:00 2001 From: DanGould Date: Thu, 16 Jul 2026 17:33:39 +0800 Subject: [PATCH 3/6] Conclude settlements from classify verdicts Add Receiver::conclude, which persists the single terminal Closed(SessionOutcome) event once the caller's confidence bar is met, mapping Cooperative(txid) -> Success(txid), Fallback -> FallbackBroadcasted, and Other(txid) -> Other(txid). The persisted SessionOutcome shape (Success(Txid), the reserved Other(Txid)) was sealed with the 1.0 format freeze; this wires the settlement classifier to it and makes the classifier produce the reserved Other variant, so drop the reservation note from its docs. The Success txid is the observed settling transaction's, which for a non-SegWit sender differs from the predicted payjoin txid the receiver could compute on its own. Re-express the legacy check_for_transaction over classify via a txid-only ChainView: each found transaction is classified structurally before concluding, instead of trusting the txid probe alone. The consume-self signature and transient-error flow are unchanged. --- payjoin/src/core/receive/v2/mod.rs | 100 +++++++++++++++++++++---- payjoin/src/core/receive/v2/session.rs | 4 - 2 files changed, 85 insertions(+), 19 deletions(-) diff --git a/payjoin/src/core/receive/v2/mod.rs b/payjoin/src/core/receive/v2/mod.rs index a27995fb4..cee2c1396 100644 --- a/payjoin/src/core/receive/v2/mod.rs +++ b/payjoin/src/core/receive/v2/mod.rs @@ -1779,6 +1779,24 @@ impl Receiver { } } + /// Conclude the monitoring session with a terminal [`SettlementOutcome`] once the caller's + /// confidence bar — e.g. a confirmation depth observed via [`Receiver::classify`] — + /// is met. Persists a single `Closed` event, mapping the outcome to the persisted + /// [`SessionOutcome`]: [`SettlementOutcome::Cooperative`] → [`SessionOutcome::Success`], + /// [`SettlementOutcome::Fallback`] → [`SessionOutcome::FallbackBroadcasted`], and + /// [`SettlementOutcome::Other`] → [`SessionOutcome::Other`]. + /// + /// When to conclude is the caller's call: a 0-confirmation conclusion can still be reorged or + /// out-raced by the conflicting transaction. + pub fn conclude(self, outcome: SettlementOutcome) -> TerminalTransition { + let session_outcome = match outcome { + SettlementOutcome::Cooperative(txid) => SessionOutcome::Success(txid), + SettlementOutcome::Fallback => SessionOutcome::FallbackBroadcasted, + SettlementOutcome::Other(txid) => SessionOutcome::Other(txid), + }; + TerminalTransition::new(SessionEvent::Closed(session_outcome), ()) + } + /// 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, @@ -1804,11 +1822,10 @@ impl Receiver { )); } - 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. + let payjoin_txid = self.payjoin_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(); @@ -1820,11 +1837,18 @@ impl Receiver { self, ); } - // Payjoin transaction with SegWit inputs was detected. Complete the session, - // recording the txid of the transaction that settled it. - return MaybeFatalOrSuccessTransition::success(SessionEvent::Closed( - SessionOutcome::Success(tx_id), - )); + let view = ChainView { spends: self.spends_from_tx(&tx) }; + if let SettlementStatus::Detected { + outcome: SettlementOutcome::Cooperative(settled_txid), + .. + } = self.classify(&view) + { + // Payjoin transaction was detected. Complete the session, recording the + // txid of the transaction that settled it. + return MaybeFatalOrSuccessTransition::success(SessionEvent::Closed( + SessionOutcome::Success(settled_txid), + )); + } } Ok(None) => {} Err(e) => @@ -1833,11 +1857,18 @@ impl Receiver { // If the Payjoin proposal was not found, check the fallback transaction, as it is // the second of two transactions whose IDs the receiver is aware of. - match find_transaction(fallback_tx.compute_txid()) { - Ok(Some(_)) => - return MaybeFatalOrSuccessTransition::success(SessionEvent::Closed( - SessionOutcome::FallbackBroadcasted, - )), + let fallback_txid = fallback_tx.compute_txid(); + match find_transaction(fallback_txid) { + Ok(Some(tx)) => { + let view = ChainView { spends: self.spends_from_tx(&tx) }; + if let SettlementStatus::Detected { outcome: SettlementOutcome::Fallback, .. } = + self.classify(&view) + { + return MaybeFatalOrSuccessTransition::success(SessionEvent::Closed( + SessionOutcome::FallbackBroadcasted, + )); + } + } Ok(None) => {} Err(e) => return MaybeFatalOrSuccessTransition::transient(Error::Implementation(e), self), @@ -1845,6 +1876,20 @@ impl Receiver { MaybeFatalOrSuccessTransition::no_results(self) } + + /// Build [`OutpointSpend`] entries for every contested outpoint that `tx` spends. Used to drive + /// [`Receiver::classify`] from a single known transaction (the legacy txid probe). + /// `confirmations` is left at `0` because the txid-only probe carries no depth information. + fn spends_from_tx(&self, tx: &Transaction) -> Vec { + self.contested_outpoints() + .into_iter() + .filter(|outpoint| tx.input.iter().any(|txin| txin.previous_output == *outpoint)) + .map(|outpoint| OutpointSpend { + outpoint, + spend: Some(Spend { tx: tx.clone(), confirmations: 0 }), + }) + .collect() + } } /// Derive a mailbox endpoint on a directory given a [`ShortId`]. @@ -2508,6 +2553,31 @@ pub mod test { )); } + #[test] + fn test_monitor_conclude_maps_outcomes() { + use bitcoin::hashes::Hash as _; + + let cases = [ + ( + SettlementOutcome::Cooperative(Txid::all_zeros()), + SessionOutcome::Success(Txid::all_zeros()), + ), + (SettlementOutcome::Fallback, SessionOutcome::FallbackBroadcasted), + (SettlementOutcome::Other(Txid::all_zeros()), SessionOutcome::Other(Txid::all_zeros())), + ]; + + for (outcome, expected) in cases { + let persister = InMemoryPersister::default(); + segwit_monitor() + .conclude(outcome) + .save(&persister) + .expect("InMemoryPersister shouldn't fail"); + let inner = persister.inner.lock().expect("Shouldn't be poisoned"); + assert!(inner.is_closed, "conclude must close the session"); + assert_eq!(inner.events.last(), Some(&SessionEvent::Closed(expected))); + } + } + #[test] fn test_v2_mutable_receiver_state_closures() { let persister = InMemoryPersister::default(); diff --git a/payjoin/src/core/receive/v2/session.rs b/payjoin/src/core/receive/v2/session.rs index 7db2088a9..4e7e0329a 100644 --- a/payjoin/src/core/receive/v2/session.rs +++ b/payjoin/src/core/receive/v2/session.rs @@ -231,10 +231,6 @@ pub enum SessionOutcome { PayjoinProposalSent, /// The contested outpoints were settled by an unrecognized transaction (neither the /// Payjoin nor the fallback), identified by its txid. - /// - /// Nothing in this release produces this variant. It is reserved now because this enum is - /// deliberately exhaustive, so a new variant after 1.0 would be a semver-breaking API - /// change; naming it before the freeze lets the settlement classifier land additively. Other(bitcoin::Txid), } From 4051bf7fd23f2ff395db35c33d3aab8e9b49821e Mon Sep 17 00:00:00 2001 From: DanGould Date: Mon, 29 Jun 2026 17:09:30 +0800 Subject: [PATCH 4/6] Expose ChainBackend and classify over the ffi Add the foreign-facing settlement surface: a ChainBackend trait that reports each contested outpoint's spend status, the ChainSpend record, and SettlementStatus/SettlementOutcome uniffi mirrors. Monitor::classify queries the backend, builds a ChainView, and returns the status without persisting anything. Cooperative mirrors the core variant's payload, carrying the observed settling txid as a string. SettlementOutcome is #[non_exhaustive] in core, so the conversion keeps a wildcard arm that degrades a finer label this binding predates to Other until the binding is regenerated. --- payjoin-ffi/src/receive/mod.rs | 94 ++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/payjoin-ffi/src/receive/mod.rs b/payjoin-ffi/src/receive/mod.rs index 9daa240d5..586dc58f1 100644 --- a/payjoin-ffi/src/receive/mod.rs +++ b/payjoin-ffi/src/receive/mod.rs @@ -1503,6 +1503,77 @@ fn try_deserialize_tx( .map_err(|e| ForeignError::InternalError(e.to_string())) } +/// A spend of a single contested outpoint, as reported by a [`ChainBackend`]. +/// +/// The spending transaction is mandatory: classification is structural, and the spending txid is +/// derived from it. +#[derive(Clone, uniffi::Record)] +pub struct ChainSpend { + /// Consensus-encoded spending transaction. + pub spending_tx: Vec, + /// Confirmations of the spend. `0` means mempool-only. + pub confirmations: u32, +} + +/// A wallet-side chain oracle for settlement attribution. +/// +/// Implement this to report which transaction (if any) spent each contested outpoint, at what +/// depth. The outpoint-based successor to the txid-only [`TransactionFinder`]; drives +/// [`Monitor::classify`]. +#[uniffi::export(with_foreign)] +pub trait ChainBackend: Send + Sync { + /// Return the spend status of `outpoint`, or `None` if it is unspent / unknown. + fn spend_status(&self, outpoint: OutPoint) -> Result, ForeignError>; +} + +/// Foreign-facing mirror of [`payjoin::receive::v2::SettlementOutcome`]. +#[derive(Clone, uniffi::Enum)] +pub enum SettlementOutcome { + /// The Payjoin settled cooperatively: the spending transaction either contains the receiver's + /// contributed input or reproduces the proposal's output set (a cut-through). Carries the + /// observed settling transaction's txid. + Cooperative { txid: String }, + /// The fallback (original) transaction settled; no privacy gain. + Fallback, + /// An unrecognized transaction settled the contested outpoints. + Other { txid: String }, +} + +impl From for SettlementOutcome { + fn from(value: payjoin::receive::v2::SettlementOutcome) -> Self { + use payjoin::receive::v2::SettlementOutcome as Core; + match value { + Core::Cooperative(txid) => SettlementOutcome::Cooperative { txid: txid.to_string() }, + Core::Fallback => SettlementOutcome::Fallback, + Core::Other(txid) => SettlementOutcome::Other { txid: txid.to_string() }, + // `SettlementOutcome` is #[non_exhaustive]; a finer label this binding predates + // surfaces as `Other` with an empty txid until the binding is regenerated. + _ => SettlementOutcome::Other { txid: String::new() }, + } + } +} + +/// Foreign-facing mirror of [`payjoin::receive::v2::SettlementStatus`]. +#[derive(Clone, uniffi::Enum)] +pub enum SettlementStatus { + /// None of the contested outpoints are spent yet. + Pending, + /// A transaction spending the contested outpoints was observed. `confirmations == 0` is + /// mempool-only. + Detected { outcome: SettlementOutcome, confirmations: u32 }, +} + +impl From for SettlementStatus { + fn from(value: payjoin::receive::v2::SettlementStatus) -> Self { + use payjoin::receive::v2::SettlementStatus as Core; + match value { + Core::Pending => SettlementStatus::Pending, + Core::Detected { outcome, confirmations } => + SettlementStatus::Detected { outcome: outcome.into(), confirmations }, + } + } +} + #[uniffi::export] impl Monitor { pub fn check_for_transaction( @@ -1518,6 +1589,29 @@ impl Monitor { }, ))))) } + + /// Classify the current settlement state by querying `chain_backend` for the spend status of + /// each contested outpoint. This is the foreign-facing wrapper over the pure + /// [`payjoin::receive::v2::Receiver::classify`]: it performs the outpoint probes, then + /// classifies the resulting snapshot. It does not persist anything. + pub fn classify( + &self, + chain_backend: Arc, + ) -> Result { + let mut spends = Vec::new(); + for outpoint in self.0.contested_outpoints() { + let Some(spend) = chain_backend.spend_status(OutPoint::from(outpoint))? else { + continue; + }; + let tx = try_deserialize_tx(spend.spending_tx)?; + spends.push(payjoin::receive::v2::OutpointSpend { + outpoint, + spend: Some(payjoin::receive::v2::Spend { tx, confirmations: spend.confirmations }), + }); + } + let view = payjoin::receive::v2::ChainView { spends }; + Ok(self.0.classify(&view).into()) + } } #[derive(uniffi::Object)] From 035fd0becb9b4499e7a59ff2aff183f47dd00610 Mon Sep 17 00:00:00 2001 From: DanGould Date: Thu, 16 Jul 2026 17:36:49 +0800 Subject: [PATCH 5/6] Render settlement status in payjoin-cli monitor Drive the monitor step from classify: build a ChainView by querying the wallet for each contested outpoint (get_raw_transaction_verbose for the spending tx and its confirmations), then render the status each tick as Pending / cooperative / fallback / other -- surfacing the settling txid for cooperative and other spends -- concluding once a configurable confirmation bar is met. Status lines go through the session-scoped printer so concurrently resumed sessions stay attributable. SettlementOutcome is #[non_exhaustive], so the render match keeps a wildcard arm that shows a generic settled line for any finer label this build predates. --- payjoin-cli/src/app/v2/mod.rs | 122 +++++++++++++++++++++++++--------- payjoin-cli/src/app/wallet.rs | 17 +++-- 2 files changed, 100 insertions(+), 39 deletions(-) diff --git a/payjoin-cli/src/app/v2/mod.rs b/payjoin-cli/src/app/v2/mod.rs index 9b545f11b..fe5dbe11d 100644 --- a/payjoin-cli/src/app/v2/mod.rs +++ b/payjoin-cli/src/app/v2/mod.rs @@ -6,11 +6,11 @@ use payjoin::bitcoin::consensus::encode::serialize_hex; use payjoin::bitcoin::{Amount, FeeRate, Transaction}; use payjoin::persist::{OptionalTransitionOutcome, SessionPersister}; use payjoin::receive::v2::{ - replay_event_log as replay_receiver_event_log, HasReplyableError, Initialized, - MaybeInputsOwned, MaybeInputsSeen, Monitor, OutputsUnknown, PayjoinProposal, + replay_event_log as replay_receiver_event_log, ChainView, HasReplyableError, Initialized, + MaybeInputsOwned, MaybeInputsSeen, Monitor, OutpointSpend, OutputsUnknown, PayjoinProposal, PendingFallback as ReceiverPendingFallback, ProvisionalProposal, ReceiveSession, Receiver, - ReceiverBuilder, SessionOutcome as ReceiverSessionOutcome, UncheckedOriginalPayload, - WantsFeeRange, WantsInputs, WantsOutputs, + ReceiverBuilder, SessionOutcome as ReceiverSessionOutcome, SettlementOutcome, SettlementStatus, + Spend, UncheckedOriginalPayload, WantsFeeRange, WantsInputs, WantsOutputs, }; use payjoin::send::v2::{ replay_event_log as replay_sender_event_log, PendingFallback as SenderPendingFallback, @@ -1170,14 +1170,60 @@ impl App { } } - /// Watch the mempool for the payjoin transaction until it appears or a - /// timeout elapses. The poll/timeout loop is one logical step from the - /// session's perspective, so it stays local rather than in the driver. + /// Build a [`ChainView`] for the monitored session by querying the wallet. + /// + /// This demo wallet can only look up the two transactions whose ids the receiver predicts, so + /// it cannot surface [`SettlementOutcome::Other`] or a cut-through whose txid differs from + /// both; a wallet with a full transaction monitor would report the actual spender of each + /// contested outpoint. + fn chain_view(&self, proposal: &Receiver) -> Result { + let wallet = self.wallet(); + let payjoin_txid = proposal.payjoin_txid(); + let payjoin = wallet.get_raw_transaction_verbose(&payjoin_txid)?; + let fallback = match proposal.fallback_txid() { + Some(txid) => wallet.get_raw_transaction_verbose(&txid)?, + None => None, + }; + + let mut spends = Vec::new(); + for outpoint in proposal.contested_outpoints() { + if let Some((tx, confirmations)) = &payjoin { + if tx.input.iter().any(|txin| txin.previous_output == outpoint) { + spends.push(OutpointSpend { + outpoint, + spend: Some(Spend { tx: tx.clone(), confirmations: *confirmations }), + }); + continue; + } + } + if let Some((tx, confirmations)) = &fallback { + if tx.input.iter().any(|txin| txin.previous_output == outpoint) { + spends.push(OutpointSpend { + outpoint, + spend: Some(Spend { tx: tx.clone(), confirmations: *confirmations }), + }); + continue; + } + } + } + Ok(ChainView { spends }) + } + + /// Watch the chain for a settlement of the session's contested outpoints + /// until one reaches the confidence bar or a timeout elapses. The + /// poll/timeout loop is one logical step from the session's perspective, + /// so it stays local rather than in the driver. + // With the default CONFIRMATION_BAR of 0 the `>=` comparison below is trivially true. + #[allow(clippy::absurd_extreme_comparisons)] async fn monitor_payjoin_proposal( &self, - mut proposal: Receiver, + proposal: Receiver, persister: &ReceiverPersister, ) -> Result<()> { + // Conclude once the detected settlement has this many confirmations. 0 concludes on first + // sight (mempool), which a reorg or the conflicting double-spend can still flip. + const CONFIRMATION_BAR: u32 = 0; + // On a session resumption, the receiver will resume again in this state. let poll_interval = tokio::time::Duration::from_millis(200); let timeout_duration = tokio::time::Duration::from_secs(5); @@ -1185,44 +1231,56 @@ impl App { let mut interval = tokio::time::interval(poll_interval); interval.tick().await; - tracing::debug!("Polling for payment confirmation"); + tracing::debug!("Polling for settlement"); let result = tokio::time::timeout(timeout_duration, async { loop { interval.tick().await; - let check_result = proposal - .check_for_transaction(|txid| { - self.wallet() - .get_raw_transaction(&txid) - .map_err(|e| ImplementationError::from(e.into_boxed_dyn_error())) - }) - .save(persister); - - match check_result { - Ok(OptionalTransitionOutcome::Progress(())) => { - persister.print("Payjoin transaction detected in the mempool!"); - return Ok(()); + let view = match self.chain_view(&proposal) { + Ok(view) => view, + Err(e) => { + tracing::warn!("Chain query failed, retrying: {e}"); + continue; } - Ok(OptionalTransitionOutcome::Stasis(current_state)) => { - proposal = current_state; + }; + + match proposal.classify(&view) { + SettlementStatus::Pending => { + persister.print("Pending: no contested outpoint spent yet"); + continue; } - Err(e) if e.is_transient() => { - tracing::debug!( - "Transient error checking for transaction, retrying: {e:?}" - ); - proposal = - e.transient_state().expect("transient error carries current state"); + SettlementStatus::Detected { outcome, confirmations } => { + match &outcome { + SettlementOutcome::Cooperative(txid) => persister.print(format_args!( + "Payjoin settled by transaction {txid} ({confirmations} conf)" + )), + SettlementOutcome::Fallback => persister.print(format_args!( + "Fell back to the original transaction ({confirmations} conf)" + )), + SettlementOutcome::Other(txid) => persister.print(format_args!( + "Spent by another transaction {txid} ({confirmations} conf)" + )), + // `SettlementOutcome` is #[non_exhaustive]; render any finer label this + // build predates without losing the confirmation count. + _ => persister.print(format_args!("Settled ({confirmations} conf)")), + } + if confirmations >= CONFIRMATION_BAR { + return outcome; + } } - Err(e) => return Err(e.into()), } } }) .await; match result { - Ok(ok) => ok, + Ok(outcome) => { + proposal.conclude(outcome).save(persister)?; + persister.print("Session concluded."); + Ok(()) + } Err(_) => Err(anyhow!( - "No payjoin transaction detected in mempool within {timeout_duration:?}, stopping." + "No settlement reached the confidence bar within {timeout_duration:?}, stopping." )), } } diff --git a/payjoin-cli/src/app/wallet.rs b/payjoin-cli/src/app/wallet.rs index d2faa04ca..ac6991978 100644 --- a/payjoin-cli/src/app/wallet.rs +++ b/payjoin-cli/src/app/wallet.rs @@ -138,15 +138,18 @@ impl BitcoindWallet { } } + /// Look up a wallet transaction and its confirmation depth. + /// + /// Uses the wallet `gettransaction` RPC so a confirmed transaction is found by txid without + /// `-txindex`. Returns `None` for a transaction unknown to the wallet; `confirmations == 0` + /// means mempool-only, and the `-1` the wallet reports for a conflicted transaction is + /// clamped to `0`, since a conflicted transaction has not settled. #[cfg(feature = "v2")] - pub fn get_raw_transaction( - &self, - txid: &Txid, - ) -> Result> { - let raw_tx = tokio::task::block_in_place(|| { + pub fn get_raw_transaction_verbose(&self, txid: &Txid) -> Result> { + let wallet_tx = tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async { match self.rpc.get_transaction(txid).await { - Ok(rpc_res) => Ok(Some(rpc_res.tx)), + Ok(res) => Ok(Some(res)), Err(e) => if e.is_tx_not_found() { Ok(None) @@ -156,7 +159,7 @@ impl BitcoindWallet { } }) })?; - Ok(raw_tx) + Ok(wallet_tx.map(|res| (res.tx, res.confirmations.max(0) as u32))) } /// Get a new address from the wallet From 9fc58e53a82d90b269853781b094fc4aca53edb2 Mon Sep 17 00:00:00 2001 From: DanGould Date: Mon, 29 Jun 2026 17:10:10 +0800 Subject: [PATCH 6/6] Document the detection-not-settlement contract Spell out the Monitor contract so integrators do not trust an imprecise 0-conf verdict. The proposal and the fallback double-spend the sender's inputs, so detection is necessary but not sufficient: only confirmation resolves the race, and confirmation depth is a confidence dial, not a state transition. Document the division of labor (library knows the semantics, wallet knows the ground truth and holds the spending transaction), the classify/conclude flow, and that the legacy check_for_transaction cannot surface Other and concludes optimistically on first sight. --- payjoin/src/core/receive/v2/mod.rs | 44 ++++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/payjoin/src/core/receive/v2/mod.rs b/payjoin/src/core/receive/v2/mod.rs index cee2c1396..31ed91479 100644 --- a/payjoin/src/core/receive/v2/mod.rs +++ b/payjoin/src/core/receive/v2/mod.rs @@ -1617,14 +1617,36 @@ pub struct ChainView { /// Typestate to monitor the network for the Payjoin proposal or fallback transaction. /// -/// After the Payjoin proposal is signed and sent back to the sender, the receiver should monitor -/// the network and confirm the status of transaction (or the fallback). In this case, the status -/// can refer to whether the transaction has been broadcast, has some number of confirmations, etc. -/// The caller should decide the condition that must be satisfied for the Payjoin to be considered -/// successful. +/// After the Payjoin proposal is signed and sent back to the sender, the receiver monitors the +/// chain to learn how the sender's inputs were ultimately spent. /// -/// Call [`Receiver::check_for_transaction`] to confirm the status of the transaction in the -/// network and conclude the Payjoin session. +/// # Detection is not settlement +/// +/// This typestate reports **detection**, which is necessary but not sufficient to tell a user the +/// Payjoin happened. The Payjoin proposal and the fallback (original) transaction **double-spend +/// the sender's inputs** ([`contested_outpoints`](Receiver::contested_outpoints)); only one can +/// confirm. Which one wins is a *privacy* fact (did the coinjoin settle), and a double-spend race +/// is only resolved by confirmation — never by mempool presence. Confirmation depth is a +/// confidence dial, not a state transition: a deep reorg or the conflicting transaction can flip +/// the verdict until it is buried deep enough for the caller's purposes. +/// +/// # Division of labor +/// +/// The library knows the *semantics* (which outpoints are contested, which input is the Payjoin +/// fingerprint, which output set is the proposal's). The **wallet** knows the *ground truth* +/// (which outpoint was spent by what, at what depth, and holds the spending transaction). So the +/// library exposes the +/// primitives ([`payjoin_txid`](Receiver::payjoin_txid), +/// [`fallback_txid`](Receiver::fallback_txid), [`contested_outpoints`](Receiver::contested_outpoints), +/// [`receiver_input`](Receiver::receiver_input)) and a pure +/// [`classify`](Receiver::classify) over a caller-supplied [`ChainView`], while chain access, +/// poll cadence, and the confidence threshold stay in the wallet. The final user-facing verdict is +/// the wallet's, combining the library's labels with its own chain tracking. +/// +/// Recommended flow: poll the chain, build a [`ChainView`], call [`classify`](Receiver::classify), +/// and when the resulting confidence bar is met, [`conclude`](Receiver::conclude) the session. +/// [`check_for_transaction`](Receiver::check_for_transaction) is the legacy txid-only convenience +/// that both detects and concludes in one step. impl Receiver { /// The txid of the Payjoin proposal as the receiver built it. /// @@ -1802,11 +1824,17 @@ impl Receiver { /// closure defines the condition that counts as found — for example presence in the mempool, /// or some number of confirmations on the blockchain. /// + /// This is the legacy, txid-only convenience: it can only probe the two transaction ids the + /// receiver predicts, so it cannot detect a [`SettlementOutcome::Other`] (an unrelated spend + /// of the contested outpoints leaves both lookups returning `None` forever), and it concludes + /// on the first match — detection, not settlement. Prefer the + /// [`classify`](Receiver::classify) / [`conclude`](Receiver::conclude) flow. + /// /// 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. + /// change the TXID of the Payjoin proposal, it cannot be monitored this way. pub fn check_for_transaction( self, find_transaction: impl Fn(Txid) -> Result, ImplementationError>,