diff --git a/payjoin-cli/src/app/v2/mod.rs b/payjoin-cli/src/app/v2/mod.rs index 73247b7e8..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, @@ -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 { @@ -1160,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); @@ -1175,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 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)] diff --git a/payjoin/src/core/receive/v2/mod.rs b/payjoin/src/core/receive/v2/mod.rs index 542f5e23c..31ed91479 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,27 +1514,327 @@ 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 -/// 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. +/// +/// # 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 /// -/// Call [`Receiver::check_for_transaction`] to confirm the status of the transaction in the -/// network and conclude the Payjoin session. +/// 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. + /// + /// 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), + } + } + + /// 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, /// 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>, @@ -1550,11 +1850,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(); @@ -1566,18 +1865,18 @@ 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())); + 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), + )); } - // 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) => @@ -1586,11 +1885,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), @@ -1598,6 +1904,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`]. @@ -1636,7 +1956,7 @@ pub(crate) fn pj_uri<'a>( pub mod test { use std::str::FromStr; - use bitcoin::{Amount, FeeRate, ScriptBuf, Witness}; + 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, @@ -1774,10 +2094,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 @@ -1837,6 +2154,458 @@ 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_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 ba0670e2a..4e7e0329a 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,9 @@ 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. + Other(bitcoin::Txid), } #[cfg(test)] @@ -564,6 +572,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 +684,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 +700,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 +744,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 +756,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 +1127,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 +1135,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(())