From 34c782cf95b56f6def2a336e56b47f7ea591222d Mon Sep 17 00:00:00 2001 From: DanGould Date: Mon, 29 Jun 2026 17:06:37 +0800 Subject: [PATCH 1/5] 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, Fallback, Other} (sealed #[non_exhaustive]) 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. Precedence is Cooperative > Fallback > Other. --- payjoin/src/core/receive/v2/mod.rs | 718 ++++++++++++++++++++++++++++- 1 file changed, 717 insertions(+), 1 deletion(-) diff --git a/payjoin/src/core/receive/v2/mod.rs b/payjoin/src/core/receive/v2/mod.rs index 81b872b13..d40cf6b4e 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; @@ -1461,6 +1461,126 @@ 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 about the contested outpoints. It is recomputed on every +/// [`Receiver::classify`] call and is never persisted. The caller decides — based on the +/// `Detected` confirmation count — when its confidence bar is met and the session should +/// [`Receiver::conclude`]. +/// +/// Confirmation depth is a confidence dial, not a state transition: the payjoin proposal and the +/// fallback double-spend the sender's inputs, so a deep reorg or the conflicting transaction can +/// always flip a `Detected` verdict until it is buried deep enough for the caller's purposes. +#[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 most conservative (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. +/// +/// This enum is `#[non_exhaustive]`: settlement attribution may grow finer terminal labels in a +/// minor release, so downstream matches must carry a wildcard arm. +/// +/// 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 either structurally contains the + /// receiver's contributed input (the payjoin fingerprint) or, for an output-substitution + /// proposal, reproduces the proposal's output set (a cut-through). Either signal proves the + /// receiver's contribution was accepted and settled — a successful, privacy-improving outcome. + /// + /// Detection is structural, not txid-based, so it matches even when the spending transaction's + /// txid differs from the predicted Payjoin txid (the non-SegWit case earlier versions could not + /// monitor). The caller already holds the full spending transaction it passed into + /// [`classify`](Receiver::classify) via the [`ChainView`], so rebroadcasting or retaining the + /// cooperative transaction as evidence is the caller's to do — mirroring how the fallback path + /// hands the caller a complete transaction via + /// [`extract_tx_to_schedule_broadcast`](Receiver::extract_tx_to_schedule_broadcast). + Cooperative, + /// 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. A +/// spend always carries its full transaction ([`Spend::tx`]); the spending txid is derived from it +/// ([`bitcoin::Transaction::compute_txid`]) rather than tracked separately, so an outpoint can +/// never be reported as spent without the transaction body that structural classification needs. +#[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 cooperative classification (the input fingerprint or +/// the output-substitution match) needs the body, and the spending txid is derived from it via +/// [`bitcoin::Transaction::compute_txid`] 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 @@ -1472,6 +1592,171 @@ 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 both the Payjoin proposal and the fallback + /// contend to spend. Only one transaction can ever spend them, which is the double-spend race + /// at the heart of settlement attribution. + 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; + /// only [`Receiver::conclude`] writes a terminal event. + /// + /// Classification is structural, not txid-based. The outcome precedence is + /// **Cooperative > Fallback > Other**: + /// 1. No contested outpoint is spent → [`SettlementStatus::Pending`]. + /// 2. A contested outpoint is spent by a transaction that proves the receiver's contribution + /// settled → [`SettlementOutcome::Cooperative`]. This holds for *either* cooperative signal: + /// the spending transaction contains the receiver's contributed input + /// ([`Receiver::receiver_input`], the input fingerprint), **or** — for an + /// output-substitution proposal — the spending transaction's output set matches the + /// proposal's outputs (order-independently), gated on the proposal actually substituting the + /// original outputs. Both are structural, so this salvages the non-SegWit case where the + /// on-chain txid differs from [`Receiver::payjoin_txid`]. The input-fingerprint step + /// is skipped when the proposal contributed no receiver input (an output-substitution-only + /// Payjoin). + /// 3. Otherwise, a contested outpoint spent by [`Receiver::fallback_txid`] → + /// [`SettlementOutcome::Fallback`]. + /// 4. Otherwise → [`SettlementOutcome::Other`] carrying the unrecognized spending txid. + /// + /// Tie-break: if contested outpoints are split across multiple transactions, the precedence + /// above decides. A Cooperative wins if *any* spend matches either cooperative signal; else a + /// Fallback wins if *any* spend is by the fallback txid; else the first unrecognized spend + /// determines `Other`. The reported `confirmations` is the most conservative (minimum) over the + /// spends that support the chosen outcome. + pub fn classify(&self, view: &ChainView) -> SettlementStatus { + let contested = self.contested_outpoints(); + + // Only consider spends of contested outpoints that the caller reports as actually spent. + 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: any spend whose transaction proves the receiver's contribution settled, + // via either signal — the input fingerprint (the receiver's contributed input, when the + // proposal has one) or an order-independent match of the spending transaction's output + // set against the proposal's (a cut-through, gated on the proposal actually substituting + // the original outputs; otherwise the proposal is the fallback). Both signals are + // structural, so a txid change from a non-SegWit signature does not hide them. + 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, + 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, @@ -1779,6 +2064,437 @@ 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 (its substituted outputs and + /// the sender's inputs). A spend whose output set matches this is a cut-through. + 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() { + // A proposal that contributes no receiver input must not panic in classify. Previously + // `receiver_input` was unwrapped before the Pending guard, panicking on every call for an + // output-substitution-only session even when nothing was spent. + 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, + 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, + 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, + 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, + 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 most conservative (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 2bbed99dfd3fecc02290d3d4fc49d7f19155cf44 Mon Sep 17 00:00:00 2001 From: DanGould Date: Mon, 29 Jun 2026 17:09:14 +0800 Subject: [PATCH 2/5] Conclude settlements and grow SessionOutcome Add Receiver::conclude, which persists the single terminal Closed(SessionOutcome) event once the caller's confidence bar is met, mapping Cooperative -> Success, Fallback -> FallbackBroadcasted, and Other -> Other. Grow the persisted SessionOutcome for settlement attribution: collapse Success to a unit variant (the spending transaction is chain data the caller already holds, so witnesses are not persisted), add Other(Txid) mapped to SessionStatus::Failed, and seal the enum #[non_exhaustive] with an aborted() constructor so downstream crates keep building the replay sentinel. Re-express the legacy check_for_transaction over classify via a txid-only ChainView, concluding a unit Success. Update the payjoin-cli SessionOutcome matches and history construction, and the integration assertions, for the unit Success. --- payjoin-cli/src/app/v2/mod.rs | 15 +++- payjoin/src/core/receive/v2/mod.rs | 111 +++++++++++++++++++------ payjoin/src/core/receive/v2/session.rs | 59 ++++++++++--- payjoin/tests/integration.rs | 38 +++------ 4 files changed, 154 insertions(+), 69 deletions(-) diff --git a/payjoin-cli/src/app/v2/mod.rs b/payjoin-cli/src/app/v2/mod.rs index a43dcdf8f..0b103b99c 100644 --- a/payjoin-cli/src/app/v2/mod.rs +++ b/payjoin-cli/src/app/v2/mod.rs @@ -82,10 +82,13 @@ impl StatusText for ReceiveSession { ReceiveSession::PendingFallback(_) => "Pending fallback handling", ReceiveSession::Closed(session_outcome) => match session_outcome { ReceiverSessionOutcome::Aborted => "Session aborted", - ReceiverSessionOutcome::Success(_) => "Session success, Payjoin proposal was broadcasted", + ReceiverSessionOutcome::Success => + "Session success, Payjoin settled cooperatively", ReceiverSessionOutcome::FallbackBroadcasted => "Fallback broadcasted", ReceiverSessionOutcome::PayjoinProposalSent => "Payjoin proposal sent, skipping monitoring as the sender is spending non-SegWit inputs", + ReceiverSessionOutcome::Other(_) => "Settled by an unrecognized transaction", + _ => "Session closed", }, } } @@ -437,7 +440,7 @@ impl AppTrait for App { let row = SessionHistoryRow { session_id, role: Role::Receiver, - status: ReceiveSession::Closed(ReceiverSessionOutcome::Aborted), + status: ReceiveSession::Closed(ReceiverSessionOutcome::aborted()), completed_at: None, error_message: Some(e.to_string()), }; @@ -492,7 +495,7 @@ impl AppTrait for App { let row = SessionHistoryRow { session_id, role: Role::Receiver, - status: ReceiveSession::Closed(ReceiverSessionOutcome::Aborted), + status: ReceiveSession::Closed(ReceiverSessionOutcome::aborted()), completed_at: Some(completed_at), error_message: Some(e.to_string()), }; @@ -619,7 +622,7 @@ impl App { }, ReceiveSession::PendingFallback(receiver) => receiver, ReceiveSession::Closed( - ReceiverSessionOutcome::Success(_) + ReceiverSessionOutcome::Success | ReceiverSessionOutcome::FallbackBroadcasted | ReceiverSessionOutcome::PayjoinProposalSent, ) => { @@ -638,6 +641,10 @@ impl App { } return Ok(()); } + ReceiveSession::Closed(_) => { + println!("Session {session_id} is already closed. 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 d40cf6b4e..b6897fafd 100644 --- a/payjoin/src/core/receive/v2/mod.rs +++ b/payjoin/src/core/receive/v2/mod.rs @@ -1757,6 +1757,25 @@ 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. This persists a single `Closed(SessionOutcome)` event and closes the session. + /// + /// Concluding is the caller's decision, not the library's: detection is necessary but not + /// sufficient, and a 0-confirmation conclusion can still be reorged or out-raced by the + /// conflicting (double-spending) transaction. The outcome maps to the persisted + /// [`SessionOutcome`]: [`SettlementOutcome::Cooperative`] → [`SessionOutcome::Success`], + /// [`SettlementOutcome::Fallback`] → [`SessionOutcome::FallbackBroadcasted`], and + /// [`SettlementOutcome::Other`] → [`SessionOutcome::Other`]. + pub fn conclude(self, outcome: SettlementOutcome) -> TerminalTransition { + let session_outcome = match outcome { + SettlementOutcome::Cooperative => SessionOutcome::Success, + 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, @@ -1782,11 +1801,13 @@ 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. + // This legacy path is the degenerate case of `classify` over a txid-only chain probe: it + // can only look up the two transactions whose IDs the receiver predicts, so it cannot + // surface `Other`. The first such transaction it finds concludes the session. + 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(); @@ -1795,18 +1816,16 @@ impl Receiver { ImplementationError::from(format!("Payjoin transaction ID mismatch. Expected: {payjoin_txid}, Got: {tx_id}").as_str()), )); } - // TODO: should we check for witness and scriptsig on the tx? - let mut sender_witnesses = vec![]; - - for i in self.state.psbt_context.sender_input_indexes() { - let input = - tx.input.get(i).expect("sender_input_indexes should return valid indices"); - sender_witnesses.push((input.script_sig.clone(), input.witness.clone())); + let view = ChainView { spends: self.spends_from_tx(&tx) }; + if let SettlementStatus::Detected { + outcome: SettlementOutcome::Cooperative, .. + } = self.classify(&view) + { + // Payjoin transaction was detected. Complete the session. + return MaybeFatalOrSuccessTransition::success(SessionEvent::Closed( + SessionOutcome::Success, + )); } - // Payjoin transaction with SegWit inputs was detected. Log the signatures and complete the session. - return MaybeFatalOrSuccessTransition::success(SessionEvent::Closed( - SessionOutcome::Success(sender_witnesses), - )); } Ok(None) => {} Err(e) => return MaybeFatalOrSuccessTransition::transient(Error::Implementation(e)), @@ -1814,17 +1833,38 @@ 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)), } MaybeFatalOrSuccessTransition::no_results(self.clone()) } + + /// 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`]. @@ -1863,7 +1903,7 @@ pub(crate) fn pj_uri<'a>( pub mod test { use std::str::FromStr; - use bitcoin::{FeeRate, ScriptBuf, Witness}; + use bitcoin::{FeeRate, ScriptBuf}; use once_cell::sync::Lazy; use payjoin_test_utils::{ BoxError, EXAMPLE_URL, KEM, KEY_ID, ORIGINAL_PSBT, PARSED_ORIGINAL_PSBT, @@ -2001,10 +2041,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)) ); // Fallback was broadcasted, should progress to success @@ -2495,6 +2532,28 @@ pub mod test { )); } + #[test] + fn test_monitor_conclude_maps_outcomes() { + use bitcoin::hashes::Hash as _; + + let cases = [ + (SettlementOutcome::Cooperative, SessionOutcome::Success), + (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 b79431787..031a94417 100644 --- a/payjoin/src/core/receive/v2/session.rs +++ b/payjoin/src/core/receive/v2/session.rs @@ -164,9 +164,12 @@ impl SessionHistory { // a `Closed` outcome is done regardless of whether its expiration has elapsed. match self.events.last() { Some(SessionEvent::Closed(outcome)) => match outcome { - SessionOutcome::Success(_) | SessionOutcome::PayjoinProposalSent => + SessionOutcome::Success | SessionOutcome::PayjoinProposalSent => SessionStatus::Completed, - SessionOutcome::Aborted => SessionStatus::Failed, + // An unrecognized transaction settled the contested outpoints: the privacy-improving + // Payjoin did not happen and it is not a clean fallback either, so surface it as a + // failure the caller should review. + SessionOutcome::Aborted | SessionOutcome::Other(_) => SessionStatus::Failed, SessionOutcome::FallbackBroadcasted => SessionStatus::FallbackBroadcasted, }, Some(SessionEvent::Cancelled | SessionEvent::ProtocolFailed) => @@ -211,10 +214,16 @@ pub enum SessionEvent { } /// Represents all possible outcomes for a closed Payjoin session +/// +/// This enum is `#[non_exhaustive]`: settlement attribution may grow new terminal labels in a +/// minor release. Match it with a wildcard arm, and construct the abort sentinel with +/// [`SessionOutcome::aborted`] rather than naming the variant directly. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[non_exhaustive] pub enum SessionOutcome { - /// Payjoin completed successfully - Success(Vec<(bitcoin::ScriptBuf, bitcoin::Witness)>), + /// The Payjoin settled cooperatively (the receiver's contribution was accepted and settled). + /// Terminal form of [`SettlementOutcome::Cooperative`](super::SettlementOutcome::Cooperative). + Success, /// Payjoin was not successful Aborted, /// Fallback transaction was broadcasted @@ -223,6 +232,19 @@ 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. This is the terminal form of + /// [`SettlementOutcome::Other`](super::SettlementOutcome::Other). + Other(bitcoin::Txid), +} + +impl SessionOutcome { + /// Construct the [`SessionOutcome::Aborted`] sentinel. + /// + /// `SessionOutcome` is `#[non_exhaustive]`, so downstream crates cannot name its variants when + /// constructing a value. This constructor lets callers build the abort sentinel they use to + /// represent, for example, a replay failure. + pub fn aborted() -> Self { SessionOutcome::Aborted } } #[cfg(test)] @@ -416,7 +438,7 @@ mod tests { let success = SessionHistory::new(vec![ SessionEvent::Created(session_context.clone()), - SessionEvent::Closed(SessionOutcome::Success(vec![])), + SessionEvent::Closed(SessionOutcome::Success), ]); assert_eq!(success.status(), SessionStatus::Completed); @@ -437,6 +459,21 @@ mod tests { assert_eq!(still_open.status(), SessionStatus::Expired); } + #[test] + fn closed_other_outcome_reports_failed() { + use bitcoin::hashes::Hash as _; + + let session_context = SHARED_CONTEXT.clone(); + let other = SessionHistory::new(vec![ + SessionEvent::Created(session_context), + SessionEvent::Closed(SessionOutcome::Other(bitcoin::Txid::all_zeros())), + ]); + assert_eq!(other.status(), SessionStatus::Failed); + + // The non-exhaustive abort sentinel is reachable through the public constructor. + assert_eq!(SessionOutcome::aborted(), SessionOutcome::Aborted); + } + #[tokio::test] async fn test_replaying_closed_session_past_expiration_is_not_expired() { let expiration = (SystemTime::now() - Duration::from_secs(1)).try_into().unwrap(); @@ -447,11 +484,11 @@ 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)) .expect("in memory persister save should not fail"); let (state, _) = replay_event_log(&persister).expect("closed session should replay successfully"); - assert!(matches!(state, ReceiveSession::Closed(SessionOutcome::Success(_)))); + assert!(matches!(state, ReceiveSession::Closed(SessionOutcome::Success))); let persister = InMemoryAsyncPersister::::default(); persister @@ -459,13 +496,13 @@ 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)) .await .expect("in memory async persister save should not fail"); let (state, _) = replay_event_log_async(&persister) .await .expect("closed session should replay successfully"); - assert!(matches!(state, ReceiveSession::Closed(SessionOutcome::Success(_)))); + assert!(matches!(state, ReceiveSession::Closed(SessionOutcome::Success))); } #[tokio::test] @@ -826,7 +863,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)); let test = SessionHistoryTest { events, @@ -834,7 +871,7 @@ 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), }; 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 3a59409ba..909511030 100644 --- a/payjoin/tests/integration.rs +++ b/payjoin/tests/integration.rs @@ -625,23 +625,14 @@ 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 cooperative 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 + )), + "The last event of the persister should be a cooperative SessionOutcome::Success", ); assert_eq!(session_history.status(), SessionStatus::Completed); Ok(()) @@ -707,23 +698,14 @@ 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 cooperative 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 + )), + "The last event of the persister should be a cooperative SessionOutcome::Success", ); assert_eq!(session_history.status(), SessionStatus::Completed); Ok(()) From e7897a55cac489948d471ccb17930e84d39d922e Mon Sep 17 00:00:00 2001 From: DanGould Date: Mon, 29 Jun 2026 17:09:30 +0800 Subject: [PATCH 3/5] 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. 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 | 95 ++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/payjoin-ffi/src/receive/mod.rs b/payjoin-ffi/src/receive/mod.rs index b271d9725..c9b5d9ec4 100644 --- a/payjoin-ffi/src/receive/mod.rs +++ b/payjoin-ffi/src/receive/mod.rs @@ -1503,6 +1503,78 @@ fn try_deserialize_tx( .map_err(|e| ForeignError::InternalError(e.to_string())) } +/// A spend of a single contested outpoint, as reported by a [`ChainBackend`]. +/// +/// Returned only for a spent outpoint; an unspent outpoint is reported as `None` from +/// [`ChainBackend::spend_status`]. The spending transaction is mandatory: it is required to classify +/// a Payjoin structurally, and the spending txid is derived from it. +#[derive(Clone, uniffi::Record)] +pub struct ChainSpend { + /// Consensus-encoded spending transaction. Required to classify a Payjoin structurally. + 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. It is the outpoint-based successor to the txid-only [`TransactionFinder`], driving the +/// richer [`Monitor::classify`] flow. +#[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). The caller already + /// holds the spending transaction it reported through its [`ChainBackend`]. + Cooperative, + /// 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 => SettlementOutcome::Cooperative, + 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 +1590,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 46f1cf0080d219c625a0673cf40eb36a7c17d19e Mon Sep 17 00:00:00 2001 From: DanGould Date: Mon, 29 Jun 2026 17:09:46 +0800 Subject: [PATCH 4/5] Render settlement status in payjoin-cli monitor Drive the monitor loop 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, concluding once a configurable confirmation bar is met. --- payjoin-cli/src/app/v2/mod.rs | 111 +++++++++++++++++++++++++++------- payjoin-cli/src/app/wallet.rs | 19 +++--- 2 files changed, 102 insertions(+), 28 deletions(-) diff --git a/payjoin-cli/src/app/v2/mod.rs b/payjoin-cli/src/app/v2/mod.rs index 0b103b99c..b340cedb6 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}; 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, @@ -974,11 +974,60 @@ impl App { return self.monitor_payjoin_proposal(session, persister).await; } + /// 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 (the + /// Payjoin proposal and the fallback), so it cannot attribute a spend to an unrelated third + /// transaction. A wallet with a full transaction monitor would instead report the actual + /// spender of each contested outpoint, which is what surfaces [`SettlementOutcome::Other`] and + /// an output-substitution [`SettlementOutcome::Cooperative`] whose txid the receiver cannot + /// predict. + 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 }) + } + + // The confidence bar below is configurable; with the default of 0 the `>=` comparison is + // trivially true, but it is meaningful once a maintainer raises the bar to require depth. + #[allow(clippy::absurd_extreme_comparisons)] async fn monitor_payjoin_proposal( &self, proposal: Receiver, persister: &ReceiverPersister, ) -> Result<()> { + // Confidence bar: conclude once the detected settlement has at least this many + // confirmations. 0 concludes as soon as the transaction is seen (mempool) -- fast, but a + // reorg or the conflicting double-spend can still flip it. Raise it to require depth. + 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); @@ -986,35 +1035,55 @@ 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(())) => { - println!("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; + } + }; + + match proposal.classify(&view) { + SettlementStatus::Pending => { + println!("Pending: no contested outpoint spent yet"); + continue; + } + SettlementStatus::Detected { outcome, confirmations } => { + match &outcome { + SettlementOutcome::Cooperative => + println!("Payjoin settled ({confirmations} conf)"), + SettlementOutcome::Fallback => println!( + "Fell back to the original transaction ({confirmations} conf)" + ), + SettlementOutcome::Other(txid) => println!( + "Spent by another transaction {txid} ({confirmations} conf)" + ), + // `SettlementOutcome` is #[non_exhaustive]; render any finer label this + // build predates without losing the confirmation count. + _ => println!("Settled ({confirmations} conf)"), + } + if confirmations >= CONFIRMATION_BAR { + return outcome; + } } - Ok(OptionalTransitionOutcome::Stasis(_)) => continue, - Err(_) => continue, } } }) .await; match result { - Ok(ok) => ok, + Ok(outcome) => { + proposal.conclude(outcome).save(persister)?; + println!("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..061d11ae7 100644 --- a/payjoin-cli/src/app/wallet.rs +++ b/payjoin-cli/src/app/wallet.rs @@ -138,15 +138,20 @@ impl BitcoindWallet { } } + /// Look up a wallet transaction and its confirmation depth. + /// + /// Uses the wallet `gettransaction` RPC so it can find a confirmed transaction by txid + /// without `-txindex`, unlike the node `getrawtransaction`. Returns `None` if the + /// transaction is unknown to the wallet, and `Some((tx, confirmations))` otherwise, where + /// `confirmations == 0` means the transaction is only in the mempool. A negative + /// `confirmations` (the wallet reports `-1` for a conflicted/replaced transaction) is + /// clamped to `0`: 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 +161,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 e7c0dde541a29e9af2a6e6b6e133ee9b9e088bfb Mon Sep 17 00:00:00 2001 From: DanGould Date: Mon, 29 Jun 2026 17:10:10 +0800 Subject: [PATCH 5/5] 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 | 48 +++++++++++++++++++++++++----- 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/payjoin/src/core/receive/v2/mod.rs b/payjoin/src/core/receive/v2/mod.rs index b6897fafd..ddfa670a2 100644 --- a/payjoin/src/core/receive/v2/mod.rs +++ b/payjoin/src/core/receive/v2/mod.rs @@ -1583,14 +1583,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. /// @@ -1781,11 +1803,21 @@ 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 is the degenerate case of + /// [`classify`](Receiver::classify) that can only probe the two transaction ids the receiver + /// predicts, so it cannot detect a [`SettlementOutcome::Other`] (an unrelated transaction + /// spending the contested outpoints leaves both lookups returning `None` forever). It also + /// concludes on the first match, so a mempool-satisfying closure can log a `Success` that the + /// conflicting fallback could still win — **detection is not settlement** (see the typestate + /// docs and the [`classify`](Receiver::classify) / [`conclude`](Receiver::conclude) pair for + /// the outpoint-based flow that resolves the double-spend race and salvages the non-SegWit + /// case). + /// /// 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>,