From 411246b663f930610b30502278aa659b14308429 Mon Sep 17 00:00:00 2001 From: spacebear Date: Mon, 19 Oct 2026 15:47:22 +0200 Subject: [PATCH 1/5] Seal fallback_tx away from downstream crates The sealed `FallbackTx` trait was `pub` inside a private module, which makes it unnameable from outside the crate but still reachable through the public `HasFallbackTx` bound. Rust lets generic code call the methods of a reachable supertrait, so a downstream function bounded on `S: HasFallbackTx` could call `receiver.fallback_tx()` and read the fallback transaction from any state, bypassing the `cancel` path and the Cancelled event it records. Narrow the trait to `pub(crate)`. The compiler then rejects the call with E0624 even inside a generic function, while code written against `HasFallbackTx` keeps compiling. A private supertrait on a public trait has only been the `private_bounds` lint since Rust 1.74, which the MSRV of 1.85 covers, so the two allows mark the mismatch as deliberate. A compile_fail doctest guards the seal; its error code is enforced on nightly and ignored on stable. --- payjoin/CHANGELOG.md | 6 ++++++ payjoin/src/core/receive/v2/mod.rs | 16 ++++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/payjoin/CHANGELOG.md b/payjoin/CHANGELOG.md index 055cbfffe..5ec2e6625 100644 --- a/payjoin/CHANGELOG.md +++ b/payjoin/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +- Close a leak in the `HasFallbackTx` seal. Generic code bounded on the trait + could call the sealed `fallback_tx` method, which was never part of the + documented API; such code no longer compiles. Read the fallback transaction + from `Receiver::fallback_tx` after `cancel`, or from + `SessionHistory::fallback_tx`. + ## 1.2.0 This release adds a non-blocking interface to the receiver state machine. diff --git a/payjoin/src/core/receive/v2/mod.rs b/payjoin/src/core/receive/v2/mod.rs index bbb0bbc3c..74284fdb7 100644 --- a/payjoin/src/core/receive/v2/mod.rs +++ b/payjoin/src/core/receive/v2/mod.rs @@ -336,7 +336,7 @@ mod sealed { impl State for super::Monitor {} impl State for super::PendingFallback {} - pub trait FallbackTx: State { + pub(crate) trait FallbackTx: State { fn fallback_tx(&self) -> bitcoin::Transaction; } @@ -407,9 +407,21 @@ impl State for S {} /// Marker trait for receiver protocol states that hold a verified broadcastable /// fallback transaction. /// -/// This trait is sealed to prevent external implementations. +/// This trait is sealed to prevent external implementations. Its methods are not callable +/// outside this crate. Use [`Receiver::cancel`] to obtain the fallback transaction. +/// +/// ```compile_fail,E0624 +/// use payjoin::bitcoin::Transaction; +/// use payjoin::receive::v2::{HasFallbackTx, Receiver}; +/// +/// fn fallback_tx(receiver: &Receiver) -> Transaction { +/// receiver.fallback_tx() +/// } +/// ``` +#[allow(private_bounds)] pub trait HasFallbackTx: sealed::FallbackTx {} +#[allow(private_bounds)] impl HasFallbackTx for T {} /// A higher-level receiver construct which will be taken through different states through the From d298cfc0c0dda0ef801f77a72def10a25292dc06 Mon Sep 17 00:00:00 2001 From: xstoicunicornx Date: Tue, 27 Oct 2026 01:05:41 +0000 Subject: [PATCH 2/5] Classify Payjoin settlements in receiver sessions After posting the proposal the receiver still does not know how the sender's inputs were spent. The proposal and the fallback double-spend them, so the receiver holds the only record that can tell which transaction represents which. Expose that as a pure classify_tx over a transaction the wallet found. Wallets should pass every transaction they receive to classify_tx on each open session, rather than probing for predicted txids. A session in any state with a fallback can see the fallback or a re-spend of the sender's inputs, not only one in Monitor. A transaction unrelated to a session is rejected by an outpoint comparison before any hashing, so checking it against every session is cheap. So classify_tx lives on every HasFallbackTx state. The sealed FallbackTx trait now returns a borrowed original PSBT, with fallback_tx derived from it as a default method, and a payjoin_psbt that defaults to None. PayjoinProposal and Monitor return their finalized proposal. PayjoinProposal counts because a post can reach the sender even when persisting the transition out of that state fails. ProvisionalProposal returns None: its unsigned transaction matches what finalization produces, but the sender cannot have it yet. Before PayjoinProposal, classify_tx never reports Payjoin. The trait is pub(crate), so generic code bounded on HasFallbackTx cannot call original_psbt or payjoin_psbt from outside the crate any more than fallback_tx. Add a compile_fail doctest for each next to the one guarding fallback_tx. Each needs its own, since one test with several calls would pass while any one of them still failed to compile. classify_tx answers whether this session's proposal or fallback settled, not what happened to the prevouts. Its outcome is Payjoin for the proposal, Fallback for the fallback, and Unrecognized for any other transaction that spends a contested prevout. A transaction spending none of them says nothing about the session, so it gets None rather than Unrecognized. The outcome carries no txid, since the caller passed in the transaction and already has it. Identity compares the transaction's txid, with its script_sigs cleared, against each PSBT's unsigned transaction. Signing fills only script_sig and witness, and the txid already excludes the witness, so for a stable session this is the plain txid check. It also recognizes the proposal after a non-SegWit sender's signature changed its txid, and the fallback after a third party malleated it. Matching on the receiver's inputs or the proposal's output set would also accept a different transaction that happens to share them. A reordered output set is a different transaction no receiver signature covers. The fallback is checked first. The two only coincide when the proposal is the original unchanged, and then no cooperation happened. Because every candidate spends the contested prevouts, one transaction is the whole answer, and choosing which to ask about is the wallet's job. Keep the contested prevouts private. A wallet that classifies every transaction it receives does not need to watch them: sync by script finds the fallback, which pays the receiver, and the proposal, which spends the receiver's inputs or pays its outputs. The one proposal sync misses spends no receiver input and pays only scripts the wallet does not own, as when it forwards the payment to a third party. The wallet chose those scripts itself, so it is the wallet's job to watch them, across restarts too. Document that on substitute_receiver_script and Monitor. A wallet that classifies what it receives has no use for a predicted txid, the fallback, or the receiver's prevouts, and SessionHistory::fallback_tx already returns the fallback for a caller that wants it. It does need a deadline: nothing else ends a session the sender never settles, or settles with a re-spend the wallet never sees. The session's expiration stays that deadline. Past it, replay returns the expired error carrying the fallback, as for any open session, and wallets already broadcast on that error. Document it on Monitor and pin it with a test. check_for_transaction still probes by txid, so compute the proposal's txid privately from the posted proposal. Its txid then includes the script_sigs of the receiver's finalized inputs. The proposal carries no sender signatures, and a SegWit sender adds only witnesses, so this is the txid the broadcast transaction has. Hashing the unsigned proposal instead misses a legacy or P2SH-wrapped receiver input and predicts a txid that never appears on chain. proposal_txid_is_stable checks only the sender's inputs, so it has the same flaw. Before the receiver signs there is no script_sig to read, and fixing it there would mean inferring from script types. Deprecate it in favor of classify_tx, which recognizes the proposal whatever its txid, and narrow its doc to what it checks. classify_tx returns a Settlement rather than the bare outcome, and Settlement::close ends the session once the wallet's confirmation bar is met. Settlement has private fields and no constructor, so only classify_tx makes one, and a session can close this way only after a transaction spent its contested prevouts. Then no fallback needs broadcasting: the fallback is that transaction or conflicts with it. Closing without that evidence would let a wallet end a session holding a fallback it never saw, so every other way out still goes through cancel, which hands the fallback over. Settlement takes no session, so it cannot be paired with the wrong one. It is not Serialize, so it cannot be forged by deserializing. No SettlementOutcome is ever persisted: only confirmation resolves a double-spend race and a reorg can always reverse it, while the log is append-only and Closed is terminal, so a verdict written there would be permanent and could later be wrong. Closing records what the protocol did. A Monitor session closes as PayjoinProposalSent whichever transaction settled it. A PayjoinProposal session settled by its Payjoin closes the same way: the sender signing the proposal proves the post reached it, and a reorg cannot undo that. Any other settlement before the handoff closes as Aborted. Keeping the session open until the wallet closes it also makes the log the record of which sessions still owe an answer, which a wallet-side cache cannot replace because a restored backup brings the logs back without it. A wallet that gives up on a session nothing settled cancels it and broadcasts the fallback. Add ReceiveSession::classify_tx, which dispatches to the state's receiver and returns None for states without a fallback. A wallet that replays its open sessions then classifies without matching on every state, and closes through the Settlement it gets back. Re-express check_for_transaction over classify_tx so a found transaction is confirmed as the proposal rather than trusted from the txid probe alone, and route its non-SegWit exit through the private txid helper. Deprecate it in favor of classify_tx and Settlement::close. It closes on first sight rather than at a confirmation bar, never sees a spend that is neither transaction, and closes an unstable session without checking at all. The binding and the CLI still call the deprecated methods, so allow the deprecation at those call sites. Both deprecations use since = "0.0.0-unreleased", a placeholder the release replaces with the real version rather than this commit guessing it. The existing Monitor tests keep covering check_for_transaction, its early exit for non-SegWit senders included, under allow(deprecated). New regression tests cover its probe of a txid with a receiver script_sig, and proposal_txid_is_stable, until both are removed. The new tests classify each kind of spend and close the Settlement from MaybeInputsOwned, PayjoinProposal and Monitor, checking the outcome each writes. --- .github/CONTRIBUTING.md | 2 +- payjoin-cli/src/app/v2/mod.rs | 1 + payjoin-ffi/src/receive/mod.rs | 2 + payjoin/CHANGELOG.md | 31 ++ payjoin/src/core/receive/v2/mod.rs | 623 ++++++++++++++++++++++--- payjoin/src/core/receive/v2/session.rs | 74 +++ payjoin/tests/integration.rs | 117 +++-- 7 files changed, 713 insertions(+), 137 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index a97859a18..86d721eab 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -120,7 +120,7 @@ If the change has no user-visible effect (refactoring, tests, CI, comments), say Mark a deprecation with the version placeholder instead of guessing the next release: ```rust -#[deprecated(since = "0.0.0-unreleased", note = "use `Receiver::classify`")] +#[deprecated(since = "0.0.0-unreleased", note = "use `Receiver::classify_tx`")] ``` `contrib/release/bump.sh` replaces it with the crate's version in the release PR, and the release checks fail if one is left. rustc warns callers about it like any other deprecation. Don't use `TBD`, which rustc treats as a future deprecation and does not warn about. diff --git a/payjoin-cli/src/app/v2/mod.rs b/payjoin-cli/src/app/v2/mod.rs index 924129b13..051622e07 100644 --- a/payjoin-cli/src/app/v2/mod.rs +++ b/payjoin-cli/src/app/v2/mod.rs @@ -1168,6 +1168,7 @@ 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. + #[allow(deprecated)] async fn monitor_payjoin_proposal( &self, mut proposal: Receiver, diff --git a/payjoin-ffi/src/receive/mod.rs b/payjoin-ffi/src/receive/mod.rs index b13dacb0d..1688f31a3 100644 --- a/payjoin-ffi/src/receive/mod.rs +++ b/payjoin-ffi/src/receive/mod.rs @@ -1908,6 +1908,7 @@ impl Monitor { /// /// Returns a [`MonitorTransition`] that, once persisted, completes /// the session if a transaction is found. + #[allow(deprecated)] pub fn check_for_transaction( &self, find_transaction: Arc, @@ -1939,6 +1940,7 @@ macro_rules! impl_proposal_txid_is_stable { /// transaction ID: any ID derived from the proposal before the sender /// signs will never appear on the network, and monitoring concludes the /// session without checking. + #[allow(deprecated)] pub fn proposal_txid_is_stable(&self) -> bool { self.0.proposal_txid_is_stable() } diff --git a/payjoin/CHANGELOG.md b/payjoin/CHANGELOG.md index 5ec2e6625..ada6469a8 100644 --- a/payjoin/CHANGELOG.md +++ b/payjoin/CHANGELOG.md @@ -8,6 +8,37 @@ from `Receiver::fallback_tx` after `cancel`, or from `SessionHistory::fallback_tx`. +### API Additions + +- Add settlement classification to `receive::v2::Receiver`. On every state + that holds a fallback transaction, `classify_tx` returns a `Settlement` + for a transaction spending the sender's original prevouts. Its `outcome` + is a `SettlementOutcome` saying whether that transaction is the Payjoin + proposal, the fallback, or neither. `ReceiveSession::classify_tx` does the + same for a session in any state. Wallets should pass every transaction + they receive to `classify_tx` on each open session. It recognizes the + proposal even after a non-SegWit sender's signature changes its txid, and + never reports `Payjoin` before `PayjoinProposal` +- Add `Settlement::close`, which ends a session once its settlement meets + the wallet's confirmation bar, without broadcasting the fallback. Only + `classify_tx` makes a `Settlement`, so a session closes this way only after + a transaction has settled it. It records `PayjoinProposalSent` from + `Monitor`, and from `PayjoinProposal` when the Payjoin settled it, and + `Aborted` otherwise + +### Deprecations + +- `Receiver::check_for_transaction` is deprecated. It closes the + session on first sight of a predicted txid, never sees a spend that is + neither the proposal nor the fallback, and closes a session with non-SegWit + sender inputs without checking. Use `classify_tx` and `Settlement::close` + instead. It now probes a txid that includes the script_sig of a legacy or + P2SH-wrapped receiver input. It used to probe a txid that never appears on + chain for such a session +- `Receiver::proposal_txid_is_stable` is deprecated. It checks only the + sender's inputs, so a legacy or P2SH-wrapped receiver input can change the + txid even when it returns true. Use `classify_tx` + ## 1.2.0 This release adds a non-blocking interface to the receiver state machine. diff --git a/payjoin/src/core/receive/v2/mod.rs b/payjoin/src/core/receive/v2/mod.rs index 74284fdb7..e614a37a1 100644 --- a/payjoin/src/core/receive/v2/mod.rs +++ b/payjoin/src/core/receive/v2/mod.rs @@ -29,7 +29,7 @@ use std::str::FromStr; use std::time::Duration; use bitcoin::psbt::Psbt; -use bitcoin::{Address, Amount, FeeRate, OutPoint, Script, ScriptBuf, TxOut, Txid}; +use bitcoin::{Address, Amount, FeeRate, OutPoint, Script, ScriptBuf, Transaction, TxOut, Txid}; pub use error::{CreateRequestError, SessionError}; pub(crate) use error::{InternalCreateRequestError, InternalSessionError}; use serde::de::Deserializer; @@ -149,6 +149,29 @@ pub enum ReceiveSession { } impl ReceiveSession { + /// Classify a transaction against this session. + /// + /// Returns the result of [`Receiver::classify_tx`] for states that hold a fallback, and + /// `None` for all other states. + pub fn classify_tx(&self, tx: &Transaction) -> Option { + match self { + ReceiveSession::MaybeInputsOwned(r) => r.classify_tx(tx), + ReceiveSession::MaybeInputsSeen(r) => r.classify_tx(tx), + ReceiveSession::OutputsUnknown(r) => r.classify_tx(tx), + ReceiveSession::WantsOutputs(r) => r.classify_tx(tx), + ReceiveSession::WantsInputs(r) => r.classify_tx(tx), + ReceiveSession::WantsFeeRange(r) => r.classify_tx(tx), + ReceiveSession::ProvisionalProposal(r) => r.classify_tx(tx), + ReceiveSession::PayjoinProposal(r) => r.classify_tx(tx), + ReceiveSession::Monitor(r) => r.classify_tx(tx), + ReceiveSession::Initialized(_) + | ReceiveSession::UncheckedOriginalPayload(_) + | ReceiveSession::HasReplyableError(_) + | ReceiveSession::PendingFallback(_) + | ReceiveSession::Closed(_) => None, + } + } + fn new(context: SessionContext) -> Self { ReceiveSession::Initialized(Receiver { state: Initialized {}, session_context: context }) } @@ -321,6 +344,8 @@ fn try_pending_fallback(session: ReceiveSession) -> Result bitcoin::Transaction; - } + fn original_psbt(&self) -> &bitcoin::Psbt; - impl FallbackTx for super::MaybeInputsOwned { fn fallback_tx(&self) -> bitcoin::Transaction { - self.original.psbt.clone().extract_tx_unchecked_fee_rate() + self.original_psbt().clone().extract_tx_unchecked_fee_rate() + } + + /// The finalized Payjoin proposal, if the sender may have received it. + fn payjoin_psbt(&self) -> Option<&bitcoin::Psbt> { None } + + /// The outcome a session in this state closes with once settled by `outcome`. + fn settled_session_outcome(&self, _outcome: SettlementOutcome) -> SessionOutcome { + SessionOutcome::Aborted } } + impl FallbackTx for super::MaybeInputsOwned { + fn original_psbt(&self) -> &bitcoin::Psbt { &self.original.psbt } + } + impl FallbackTx for super::MaybeInputsSeen { - fn fallback_tx(&self) -> bitcoin::Transaction { - self.original.psbt.clone().extract_tx_unchecked_fee_rate() - } + fn original_psbt(&self) -> &bitcoin::Psbt { &self.original.psbt } } impl FallbackTx for super::OutputsUnknown { - fn fallback_tx(&self) -> bitcoin::Transaction { - self.original.psbt.clone().extract_tx_unchecked_fee_rate() - } + fn original_psbt(&self) -> &bitcoin::Psbt { &self.original.psbt } } impl FallbackTx for super::WantsOutputs { - fn fallback_tx(&self) -> bitcoin::Transaction { - self.inner.original_psbt().clone().extract_tx_unchecked_fee_rate() - } + fn original_psbt(&self) -> &bitcoin::Psbt { self.inner.original_psbt() } } impl FallbackTx for super::WantsInputs { - fn fallback_tx(&self) -> bitcoin::Transaction { - self.inner.original_psbt().clone().extract_tx_unchecked_fee_rate() - } + fn original_psbt(&self) -> &bitcoin::Psbt { self.inner.original_psbt() } } impl FallbackTx for super::WantsFeeRange { - fn fallback_tx(&self) -> bitcoin::Transaction { - self.inner.original_psbt().clone().extract_tx_unchecked_fee_rate() - } + fn original_psbt(&self) -> &bitcoin::Psbt { self.inner.original_psbt() } } impl FallbackTx for super::ProvisionalProposal { - fn fallback_tx(&self) -> bitcoin::Transaction { - self.psbt_context.original_psbt.clone().extract_tx_unchecked_fee_rate() - } + fn original_psbt(&self) -> &bitcoin::Psbt { &self.psbt_context.original_psbt } } + // The proposal may have reached the sender even if persisting the post failed. impl FallbackTx for super::PayjoinProposal { - fn fallback_tx(&self) -> bitcoin::Transaction { - self.psbt_context.original_psbt.clone().extract_tx_unchecked_fee_rate() + fn original_psbt(&self) -> &bitcoin::Psbt { &self.psbt_context.original_psbt } + + fn payjoin_psbt(&self) -> Option<&bitcoin::Psbt> { Some(&self.psbt_context.payjoin_psbt) } + + // Only a settled Payjoin proves the proposal reached the sender. + fn settled_session_outcome(&self, outcome: SettlementOutcome) -> SessionOutcome { + match outcome { + SettlementOutcome::Payjoin => SessionOutcome::PayjoinProposalSent, + _ => SessionOutcome::Aborted, + } } } impl FallbackTx for super::Monitor { - fn fallback_tx(&self) -> bitcoin::Transaction { - self.psbt_context.original_psbt.clone().extract_tx_unchecked_fee_rate() + fn original_psbt(&self) -> &bitcoin::Psbt { &self.psbt_context.original_psbt } + + fn payjoin_psbt(&self) -> Option<&bitcoin::Psbt> { Some(&self.psbt_context.payjoin_psbt) } + + fn settled_session_outcome(&self, _outcome: SettlementOutcome) -> SessionOutcome { + SessionOutcome::PayjoinProposalSent } } } @@ -418,6 +454,24 @@ impl State for S {} /// receiver.fallback_tx() /// } /// ``` +/// +/// ```compile_fail,E0624 +/// use payjoin::bitcoin::Psbt; +/// use payjoin::receive::v2::{HasFallbackTx, Receiver}; +/// +/// fn original_psbt(receiver: &Receiver) -> Psbt { +/// receiver.original_psbt().clone() +/// } +/// ``` +/// +/// ```compile_fail,E0624 +/// use payjoin::bitcoin::Psbt; +/// use payjoin::receive::v2::{HasFallbackTx, Receiver}; +/// +/// fn payjoin_psbt(receiver: &Receiver) -> Option { +/// receiver.payjoin_psbt().cloned() +/// } +/// ``` #[allow(private_bounds)] pub trait HasFallbackTx: sealed::FallbackTx {} @@ -496,26 +550,68 @@ impl Receiver { ) } - /// Whether the payjoin proposal's transaction ID is knowable in advance. + /// The prevouts spent by the sender's original transaction, and so by both the fallback and + /// the Payjoin proposal. + fn contested_prevouts(&self) -> impl Iterator + '_ { + self.state.original_psbt().unsigned_tx.input.iter().map(|txin| txin.previous_output) + } + + /// Classify a transaction against this session's Payjoin proposal and fallback. /// - /// If every sender input is native SegWit, the sender's final signatures - /// live in the witness and each script_sig stays empty, so the - /// transaction ID computed from the unsigned proposal remains valid once - /// the sender re-signs — the receiver can record it (e.g. to reconcile an - /// incoming payment) and monitor the network for it. + /// Call this with each transaction the wallet receives. Signatures are not verified, so pass + /// only transactions seen on the network. If several transactions spend the sender's inputs, + /// classify the one with the most confirmations. /// - /// If any sender input finalizes with a non-empty script_sig — legacy - /// inputs, whose signatures live there, but also P2SH-wrapped SegWit - /// inputs, whose script_sig carries the redeem script push — that - /// script_sig is part of the txid preimage and changes the transaction - /// ID. Any transaction ID derived from the proposal before the sender - /// signs will never appear on the network, and - /// [`Receiver::check_for_transaction`] concludes the session - /// without monitoring. Receivers that track payments by transaction ID - /// can use this to choose a policy up front: decline to contribute and - /// let the fallback pay, or reconcile that session by script instead of - /// by transaction ID. - pub fn proposal_txid_is_stable(&self) -> bool { + /// Returns `None` if `tx` spends none of the sender's original inputs. Otherwise returns a + /// [`Settlement`] with outcome [`SettlementOutcome::Fallback`] if `tx` is the fallback, + /// [`SettlementOutcome::Payjoin`] if it is the proposal, or + /// [`SettlementOutcome::Unrecognized`] otherwise. States before [`Receiver`] + /// never return [`SettlementOutcome::Payjoin`]. + pub fn classify_tx(&self, tx: &Transaction) -> Option { + let outcome = self.settlement_outcome(tx)?; + Some(Settlement { outcome, session_outcome: self.state.settled_session_outcome(outcome) }) + } + + /// Which transaction `tx` is to this session, if it spends a contested prevout. + fn settlement_outcome(&self, tx: &Transaction) -> Option { + let spends_contested_prevout = tx + .input + .iter() + .any(|txin| self.contested_prevouts().any(|prevout| prevout == txin.previous_output)); + if !spends_contested_prevout { + return None; + } + + let mut unsigned_tx = tx.clone(); + for txin in &mut unsigned_tx.input { + txin.script_sig = ScriptBuf::new(); + } + let unsigned_txid = unsigned_tx.compute_txid(); + + if unsigned_txid == self.state.original_psbt().unsigned_tx.compute_txid() { + return Some(SettlementOutcome::Fallback); + } + if let Some(payjoin_psbt) = self.state.payjoin_psbt() { + if unsigned_txid == payjoin_psbt.unsigned_tx.compute_txid() { + return Some(SettlementOutcome::Payjoin); + } + } + Some(SettlementOutcome::Unrecognized) + } + + /// Whether every sender input finalizes with an empty script_sig, so the sender's signatures + /// leave the Payjoin proposal's txid unchanged. + /// + /// Only sender inputs are checked. A legacy or P2SH-wrapped receiver input still changes the + /// txid of the unsigned proposal. + #[deprecated( + since = "0.0.0-unreleased", + note = "checks only the sender's inputs; use `Receiver::classify_tx`" + )] + pub fn proposal_txid_is_stable(&self) -> bool { self.sender_script_sigs_are_empty() } + + /// Whether every sender input finalizes with an empty script_sig. + fn sender_script_sigs_are_empty(&self) -> bool { self.state.fallback_tx().input.iter().all(|txin| txin.script_sig.is_empty()) } } @@ -1266,6 +1362,9 @@ impl Receiver { /// Substitute the receiver output script with the provided script. /// + /// If `output_script` is not owned by the receiver's wallet, watch it to find the Payjoin + /// proposal when it settles. + /// /// Returns an updated [`Receiver`] with the substituted output. pub fn substitute_receiver_script( self, @@ -1805,7 +1904,7 @@ impl Receiver { } } -/// Completed payjoin proposal monitoring for the payjoin or fallback transaction. +/// Completed Payjoin proposal that has been posted to the sender. /// /// See [`Receiver`] for further documentation. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -1813,23 +1912,68 @@ pub struct Monitor { psbt_context: PsbtContext, } -/// 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. +/// Which transaction spent the sender's original inputs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum SettlementOutcome { + /// The Payjoin proposal. + Payjoin, + /// The fallback transaction. + Fallback, + /// Neither the Payjoin proposal nor the fallback, e.g. a fee bump or a sender double-spend. + Unrecognized, +} + +/// A transaction spending the sender's original inputs, as classified by +/// [`Receiver::classify_tx`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Settlement { + outcome: SettlementOutcome, + session_outcome: SessionOutcome, +} + +impl Settlement { + /// Returns which transaction settled the session. + pub fn outcome(&self) -> SettlementOutcome { self.outcome } + + /// Close the Payjoin session. + /// + /// Call this once the settlement has enough confirmations for the wallet. + /// + /// Returns a [`TerminalTransition`] that, once successfully persisted, closes the receiver + /// session as [`SessionOutcome::PayjoinProposalSent`] if the proposal was delivered, or + /// [`SessionOutcome::Aborted`] otherwise. + pub fn close(self) -> TerminalTransition { + TerminalTransition::new(SessionEvent::Closed(self.session_outcome), ()) + } +} + +/// Typestate for a Payjoin proposal that has been posted to the sender. /// -/// Call [`Receiver::check_for_transaction`] to confirm the status of the transaction in the -/// network and conclude the Payjoin session. +/// Call [`Receiver::classify_tx`] with each transaction the wallet receives, and +/// [`Settlement::close`] the returned settlement once it has enough confirmations. If nothing +/// settles the session before it expires, [`cancel`](Receiver::cancel) it and broadcast the +/// fallback. impl Receiver { - /// Check the network for the payjoin or fallback transaction via the `find_transaction` + /// The txid of the broadcast Payjoin proposal, or `None` if the sender's signatures change + /// it. + fn stable_payjoin_txid(&self) -> Option { + if !self.sender_script_sigs_are_empty() { + return None; + } + // Includes the script_sigs of the receiver's finalized inputs. + let payjoin_tx = + self.state.psbt_context.payjoin_psbt.clone().extract_tx_unchecked_fee_rate(); + Some(payjoin_tx.compute_txid()) + } + + /// Check the network for the Payjoin or fallback transaction via the `find_transaction` /// callback. /// /// Returns a [`MaybeFatalOrSuccessTransition`] that, once successfully persisted, either /// concludes the session if a transaction is found, or yields a [`Receiver`] to /// remain in stasis if no transaction is found yet. + #[deprecated(since = "0.0.0-unreleased", note = "use `classify_tx` and `Settlement::close`")] pub fn check_for_transaction( self, find_transaction: impl Fn(Txid) -> Result, ImplementationError>, @@ -1839,14 +1983,13 @@ impl Receiver { // If the fallback transaction included any non-SegWit inputs, then the transaction ID of // the Payjoin proposal is going to change when the sender signs their non-SegWit address // one more time. The receiver cannot monitor the transaction, and should conclude the session. - if !self.proposal_txid_is_stable() { - return MaybeFatalOrSuccessTransition::success(SessionEvent::Closed( - SessionOutcome::PayjoinProposalSent, - )); - } - - let payjoin_proposal = &self.state.psbt_context.payjoin_psbt; - let payjoin_txid = payjoin_proposal.unsigned_tx.compute_txid(); + let payjoin_txid = match self.stable_payjoin_txid() { + Some(txid) => txid, + None => + return MaybeFatalOrSuccessTransition::success(SessionEvent::Closed( + SessionOutcome::PayjoinProposalSent, + )), + }; // 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. @@ -1863,9 +2006,11 @@ impl Receiver { } // Payjoin transaction with SegWit inputs was detected. Complete the session, // recording the txid of the transaction that settled it. - return MaybeFatalOrSuccessTransition::success(SessionEvent::Closed( - SessionOutcome::Success(tx_id), - )); + if let Some(SettlementOutcome::Payjoin) = self.settlement_outcome(&tx) { + return MaybeFatalOrSuccessTransition::success(SessionEvent::Closed( + SessionOutcome::Success(tx_id), + )); + } } Ok(None) => {} Err(e) => @@ -1875,10 +2020,12 @@ 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, - )), + Ok(Some(tx)) => + if let Some(SettlementOutcome::Fallback) = self.settlement_outcome(&tx) { + return MaybeFatalOrSuccessTransition::success(SessionEvent::Closed( + SessionOutcome::FallbackBroadcasted, + )); + }, Ok(None) => {} Err(e) => return MaybeFatalOrSuccessTransition::transient(Error::Implementation(e), self), @@ -2095,6 +2242,7 @@ pub mod test { } #[test] + #[allow(deprecated)] fn test_monitor_typestate() -> Result<(), BoxError> { let psbt_ctx = native_segwit_psbt_context(); let payjoin_tx = psbt_ctx.payjoin_psbt.unsigned_tx.clone(); @@ -2197,6 +2345,7 @@ pub mod test { } #[test] + #[allow(deprecated)] fn test_nested_segwit_sender_input_txid_is_unstable() -> Result<(), BoxError> { use bitcoin::hashes::Hash; @@ -2281,6 +2430,338 @@ pub mod test { Ok(()) } + #[test] + fn test_classify_tx_payjoin_with_receiver_script_sig() { + // A receiver input with a script_sig changes the txid of the unsigned proposal. + let mut psbt_ctx = native_segwit_psbt_context(); + psbt_ctx.payjoin_psbt.inputs[1].final_script_sig = Some(ScriptBuf::from_bytes(vec![0x51])); + let unsigned_txid = psbt_ctx.payjoin_psbt.unsigned_tx.compute_txid(); + let mut broadcast_tx = psbt_ctx.payjoin_psbt.clone().extract_tx_unchecked_fee_rate(); + let mut sender_witness = Witness::new(); + sender_witness.push([0x30; 71]); // dummy signature + sender_witness.push([0x02; 33]); // dummy compressed pubkey + broadcast_tx.input[0].witness = sender_witness; + let monitor = Receiver { + state: Monitor { psbt_context: psbt_ctx }, + session_context: SHARED_CONTEXT.clone(), + }; + + assert_ne!(broadcast_tx.compute_txid(), unsigned_txid); + assert_eq!( + monitor.classify_tx(&broadcast_tx).map(|s| s.outcome()), + Some(SettlementOutcome::Payjoin) + ); + } + + #[test] + #[allow(deprecated)] + fn test_check_for_transaction_regression() { + // A receiver input with a script_sig changes the txid of the unsigned proposal. + let mut psbt_ctx = native_segwit_psbt_context(); + psbt_ctx.payjoin_psbt.inputs[1].final_script_sig = Some(ScriptBuf::from_bytes(vec![0x51])); + let payjoin_tx = psbt_ctx.payjoin_psbt.clone().extract_tx_unchecked_fee_rate(); + let fallback_tx = psbt_ctx.original_psbt.clone().extract_tx_unchecked_fee_rate(); + let monitor = Receiver { + state: Monitor { psbt_context: psbt_ctx }, + session_context: SHARED_CONTEXT.clone(), + }; + let check_for = |broadcast: &Transaction| { + let persister = InMemoryPersister::default(); + monitor + .clone() + .check_for_transaction(|txid| { + Ok((txid == broadcast.compute_txid()).then(|| broadcast.clone())) + }) + .save(&persister) + .expect("InMemoryPersister shouldn't fail"); + let inner = persister.inner.lock().expect("Shouldn't be poisoned"); + assert!(inner.is_closed); + inner.events.last().cloned() + }; + + assert_eq!( + check_for(&payjoin_tx), + Some(SessionEvent::Closed(SessionOutcome::Success(payjoin_tx.compute_txid()))) + ); + assert_eq!( + check_for(&fallback_tx), + Some(SessionEvent::Closed(SessionOutcome::FallbackBroadcasted)) + ); + } + + #[test] + #[allow(deprecated)] + fn test_proposal_txid_is_stable_regression() { + // The BIP78 test vectors use a P2SH-wrapped SegWit sender. + let native_segwit_monitor = Receiver { + state: Monitor { psbt_context: native_segwit_psbt_context() }, + session_context: SHARED_CONTEXT.clone(), + }; + assert!(native_segwit_monitor.proposal_txid_is_stable()); + assert!(!segwit_monitor().proposal_txid_is_stable()); + } + + 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 is identical to the original transaction. + fn identical_proposal_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 an output and contributes no input. + fn cutthrough_monitor() -> Receiver { + let original = PARSED_ORIGINAL_PSBT.clone(); + let mut proposal = original.clone(); + 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 finalized fallback transaction. + fn fallback_tx() -> Transaction { PARSED_ORIGINAL_PSBT.clone().extract_tx().expect("valid tx") } + + /// Returns `tx` with a non-empty script_sig on its first input, changing only its txid. + fn with_perturbed_txid(mut tx: Transaction) -> Transaction { + tx.input[0].script_sig = ScriptBuf::from_bytes(vec![0x51]); // OP_TRUE, arbitrary + tx + } + + /// Returns `tx` with a different output value, spending the same prevouts. + fn respent(mut tx: Transaction) -> Transaction { + tx.output[0].value = Amount::from_sat(tx.output[0].value.to_sat() - 1); + tx + } + + #[test] + fn test_classify_tx_ignores_unrelated_tx() { + use bitcoin::hashes::Hash as _; + + let monitor = segwit_monitor(); + let mut unrelated = PARSED_PAYJOIN_PROPOSAL.clone().unsigned_tx; + for txin in unrelated.input.iter_mut() { + txin.previous_output = OutPoint { txid: Txid::all_zeros(), vout: 9 }; + } + assert_eq!(monitor.classify_tx(&unrelated).map(|s| s.outcome()), None); + } + + #[test] + fn test_classify_tx_payjoin() { + let monitor = segwit_monitor(); + let payjoin_tx = PARSED_PAYJOIN_PROPOSAL.clone().unsigned_tx; + + assert_eq!( + monitor.classify_tx(&payjoin_tx).map(|s| s.outcome()), + Some(SettlementOutcome::Payjoin) + ); + } + + #[test] + fn test_classify_tx_payjoin_with_changed_txid() { + // A non-SegWit sender's signature changes the proposal's txid. + let monitor = segwit_monitor(); + let signed_tx = with_perturbed_txid(PARSED_PAYJOIN_PROPOSAL.clone().unsigned_tx); + assert_ne!(signed_tx.compute_txid(), PARSED_PAYJOIN_PROPOSAL.unsigned_tx.compute_txid()); + + assert_eq!( + monitor.classify_tx(&signed_tx).map(|s| s.outcome()), + Some(SettlementOutcome::Payjoin) + ); + } + + #[test] + fn test_classify_tx_payjoin_without_receiver_input() { + let monitor = cutthrough_monitor(); + let proposal_tx = monitor.state.psbt_context.payjoin_psbt.unsigned_tx.clone(); + assert!(proposal_tx + .input + .iter() + .map(|txin| txin.previous_output) + .eq(monitor.contested_prevouts())); + + assert_eq!( + monitor.classify_tx(&proposal_tx).map(|s| s.outcome()), + Some(SettlementOutcome::Payjoin) + ); + } + + #[test] + fn test_classify_tx_fallback() { + let monitor = segwit_monitor(); + assert_eq!( + monitor.classify_tx(&fallback_tx()).map(|s| s.outcome()), + Some(SettlementOutcome::Fallback) + ); + } + + #[test] + fn test_classify_tx_malleated_fallback() { + // A third party can malleate a non-SegWit fallback's script_sig. + let monitor = segwit_monitor(); + let malleated = with_perturbed_txid(fallback_tx()); + assert_ne!(malleated.compute_txid(), monitor.fallback_tx().compute_txid()); + + assert_eq!( + monitor.classify_tx(&malleated).map(|s| s.outcome()), + Some(SettlementOutcome::Fallback) + ); + } + + #[test] + fn test_classify_tx_identical_proposal_is_fallback() { + let monitor = identical_proposal_monitor(); + assert_eq!( + monitor.classify_tx(&fallback_tx()).map(|s| s.outcome()), + Some(SettlementOutcome::Fallback) + ); + } + + #[test] + fn test_classify_tx_unrecognized() { + let monitor = segwit_monitor(); + let other_tx = respent(fallback_tx()); + + assert_eq!( + monitor.classify_tx(&other_tx).map(|s| s.outcome()), + Some(SettlementOutcome::Unrecognized) + ); + } + + #[test] + fn test_classify_tx_reordered_outputs_is_unrecognized() { + let monitor = cutthrough_monitor(); + let mut reordered_tx = monitor.state.psbt_context.payjoin_psbt.unsigned_tx.clone(); + reordered_tx.output.reverse(); + + assert_eq!( + monitor.classify_tx(&reordered_tx).map(|s| s.outcome()), + Some(SettlementOutcome::Unrecognized) + ); + } + + #[test] + fn test_classify_tx_shared_prevouts_are_not_payjoin() { + // Same inputs as the proposal, different outputs. + let monitor = segwit_monitor(); + let other_tx = respent(PARSED_PAYJOIN_PROPOSAL.clone().unsigned_tx); + assert_eq!(other_tx.input, PARSED_PAYJOIN_PROPOSAL.unsigned_tx.input); + + assert_eq!( + monitor.classify_tx(&other_tx).map(|s| s.outcome()), + Some(SettlementOutcome::Unrecognized) + ); + } + + /// The events persisted by closing `settlement`. + fn close_events(settlement: Settlement) -> Vec { + let persister = InMemoryPersister::default(); + settlement.close().save(&persister).expect("InMemoryPersister shouldn't fail"); + let inner = persister.inner.lock().expect("Shouldn't be poisoned"); + assert!(inner.is_closed, "close must close the session"); + inner.events.clone() + } + + #[test] + fn test_settlement_close_from_monitor_records_no_settlement_outcome() { + let monitor = segwit_monitor(); + let closed = vec![SessionEvent::Closed(SessionOutcome::PayjoinProposalSent)]; + for tx in + [PARSED_PAYJOIN_PROPOSAL.unsigned_tx.clone(), fallback_tx(), respent(fallback_tx())] + { + let settlement = monitor.classify_tx(&tx).expect("tx spends the contested prevouts"); + assert_eq!(close_events(settlement), closed); + } + } + + #[test] + fn test_settlement_close_from_payjoin_proposal() { + let receiver = Receiver { + state: PayjoinProposal { + psbt_context: PsbtContext { + original_psbt: PARSED_ORIGINAL_PSBT.clone(), + payjoin_psbt: PARSED_PAYJOIN_PROPOSAL.clone(), + }, + }, + session_context: SHARED_CONTEXT.clone(), + }; + let cases = [ + ( + PARSED_PAYJOIN_PROPOSAL.unsigned_tx.clone(), + SettlementOutcome::Payjoin, + SessionOutcome::PayjoinProposalSent, + ), + (fallback_tx(), SettlementOutcome::Fallback, SessionOutcome::Aborted), + (respent(fallback_tx()), SettlementOutcome::Unrecognized, SessionOutcome::Aborted), + ]; + for (tx, outcome, closed) in cases { + let settlement = receiver.classify_tx(&tx).expect("tx spends the contested prevouts"); + assert_eq!(settlement.outcome(), outcome); + assert_eq!(close_events(settlement), vec![SessionEvent::Closed(closed)]); + } + } + + #[test] + fn test_settlement_close_before_proposal_is_aborted() { + let receiver = Receiver { + state: maybe_inputs_owned_v2_from_test_vector(), + session_context: SHARED_CONTEXT.clone(), + }; + let cases = [ + (fallback_tx(), SettlementOutcome::Fallback), + (respent(fallback_tx()), SettlementOutcome::Unrecognized), + (PARSED_PAYJOIN_PROPOSAL.unsigned_tx.clone(), SettlementOutcome::Unrecognized), + ]; + for (tx, outcome) in cases { + let settlement = receiver.classify_tx(&tx).expect("tx spends the contested prevouts"); + assert_eq!(settlement.outcome(), outcome); + assert_eq!( + close_events(settlement), + vec![SessionEvent::Closed(SessionOutcome::Aborted)] + ); + } + } + + #[test] + fn test_receive_session_classify_tx() { + let monitor = segwit_monitor(); + assert_eq!( + ReceiveSession::Monitor(monitor.clone()).classify_tx(&fallback_tx()), + monitor.classify_tx(&fallback_tx()) + ); + + let initialized = ReceiveSession::Initialized(Receiver { + state: Initialized {}, + session_context: SHARED_CONTEXT.clone(), + }); + assert_eq!(initialized.classify_tx(&fallback_tx()), None); + let pending_fallback = ReceiveSession::PendingFallback(Receiver { + state: PendingFallback { fallback_tx: fallback_tx() }, + session_context: SHARED_CONTEXT.clone(), + }); + assert_eq!(pending_fallback.classify_tx(&fallback_tx()), None); + assert_eq!( + ReceiveSession::Closed(SessionOutcome::Aborted).classify_tx(&fallback_tx()), + None + ); + } + #[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 63fd3c3da..27e99a6cf 100644 --- a/payjoin/src/core/receive/v2/session.rs +++ b/payjoin/src/core/receive/v2/session.rs @@ -261,6 +261,61 @@ mod tests { }; use crate::receive::{InternalPayloadError, PayloadError}; + /// The events of a session that posted its Payjoin proposal. + fn posted_proposal_events(session_context: SessionContext) -> Vec { + let persister = InMemoryPersister::::default(); + let original = original_from_test_vector(); + let maybe_inputs_owned = unchecked_receiver_from_test_vector() + .assume_interactive_receiver() + .save(&persister) + .expect("Save should not fail"); + let outputs_unknown = maybe_inputs_owned + .check_inputs_not_owned(&mut |_| Ok(false)) + .save(&persister) + .expect("No inputs should be owned") + .check_no_inputs_seen_before(&mut |_| Ok(false)) + .save(&persister) + .expect("No inputs should be seen before"); + let wants_outputs = outputs_unknown + .identify_receiver_outputs(&mut |_| Ok(true)) + .save(&persister) + .expect("Outputs should be identified"); + let wants_inputs = + wants_outputs.commit_outputs().save(&persister).expect("Save should not fail"); + let wants_fee_range = + wants_inputs.clone().commit_inputs().save(&persister).expect("Save should not fail"); + let provisional_proposal = wants_fee_range + .clone() + .apply_fee_range(None, None) + .save(&persister) + .expect("Fee range should apply"); + let payjoin_proposal = provisional_proposal + .clone() + .finalize_proposal(|psbt| Ok(psbt.clone())) + .save(&persister) + .expect("Payjoin proposal should be finalized"); + + vec![ + SessionEvent::Created(session_context), + SessionEvent::RetrievedOriginalPayload { original, reply_key: None }, + SessionEvent::CheckedBroadcastSuitability(), + SessionEvent::CheckedInputsNotOwned(), + SessionEvent::CheckedNoInputsSeenBefore(), + SessionEvent::IdentifiedReceiverOutputs(vec![0]), + SessionEvent::CommittedOutputs { + outputs: wants_inputs.state.inner.proposal.payjoin_psbt.unsigned_tx.output.clone(), + change_vout: wants_inputs.state.inner.proposal.change_vout, + }, + SessionEvent::CommittedInputs { + receiver_inputs: wants_fee_range.state.inner.proposal.receiver_inputs.clone(), + payjoin_psbt: wants_fee_range.state.inner.proposal.payjoin_psbt.clone(), + }, + SessionEvent::AppliedFeeRange(provisional_proposal.state.psbt_context.clone()), + SessionEvent::FinalizedProposal(payjoin_proposal.psbt().clone()), + SessionEvent::PostedPayjoinProposal(), + ] + } + fn unchecked_receiver_from_test_vector() -> Receiver { Receiver { state: UncheckedOriginalPayload { original: original_from_test_vector() }, @@ -1226,4 +1281,23 @@ mod tests { Ok(()) } + + #[test] + fn posted_session_past_expiration_replays_as_expired() { + let expiration = (SystemTime::now() - Duration::from_secs(1)).try_into().unwrap(); + let session_context = SessionContext { expiration, ..SHARED_CONTEXT.clone() }; + + let persister = InMemoryPersister::::default(); + let events = posted_proposal_events(session_context); + for event in events.clone() { + persister.save_event(event).expect("in memory persister save should not fail"); + } + + let err = replay_event_log(&persister).expect_err("a posted session should expire"); + assert!(err.is_expired()); + let history = SessionHistory::new(events); + assert_eq!(err.expiry_fallback_tx().cloned(), history.fallback_tx()); + assert!(history.fallback_tx().is_some()); + assert_eq!(history.status(), SessionStatus::Expired); + } } diff --git a/payjoin/tests/integration.rs b/payjoin/tests/integration.rs index 889528645..2df5f21b6 100644 --- a/payjoin/tests/integration.rs +++ b/payjoin/tests/integration.rs @@ -206,7 +206,8 @@ mod integration { use payjoin::persist::OptionalTransitionOutcome; use payjoin::receive::v2::{ replay_event_log as replay_receiver_event_log, Monitor, PayjoinProposal, - ReceiveSession, Receiver, ReceiverBuilder, SessionStatus, UncheckedOriginalPayload, + ReceiveSession, Receiver, ReceiverBuilder, SessionStatus, SettlementOutcome, + UncheckedOriginalPayload, }; use payjoin::send::v2::{replay_event_log as replay_sender_event_log, SenderBuilder}; use payjoin::send::ResponseError; @@ -563,21 +564,22 @@ mod integration { Amount::from_btc(0.0)? ); - // Receiver cannot validate that the sender has broadcasted the Payjoin proposal or the fallback transaction. - // The sender is using a non-SegWit address, so their signature is going to change the TXID. So we test whether the - // function exists early and does not call the closure. - monitoring_payment - .check_for_transaction(|_| { - panic!("when the sender is using a non-SegWit address type, the check_for_transaction function should skip the check and return success") - }) + // The sender's non-SegWit signature changes the Payjoin txid. + let settlement = monitoring_payment + .classify_tx(&broadcasted_transaction) + .expect("the transaction settles the session"); + assert_eq!(settlement.outcome(), SettlementOutcome::Payjoin); + settlement + .close() .save(&recv_persister) - .expect("receiver should successfully monitor for the payment"); + .expect("receiver should successfully close the session"); let (_session, session_history) = replay_receiver_event_log(&recv_persister)?; assert_eq!( recv_persister.load().unwrap().last(), - Some(payjoin::receive::v2::SessionEvent::Closed(payjoin::receive::v2::SessionOutcome::PayjoinProposalSent)), - "The last event of the persister should be a SessionOutcome::PayjoinProposalSent since the sender is going to change the TXID when they sign the Payjoin proposal", + Some(payjoin::receive::v2::SessionEvent::Closed( + payjoin::receive::v2::SessionOutcome::PayjoinProposalSent + )), ); assert_eq!(session_history.status(), SessionStatus::Completed); Ok(()) @@ -620,31 +622,25 @@ mod integration { Amount::from_btc(0.0)? ); - // Receiver should be able to validate that the sender has broadcasted the Payjoin proposal. - monitoring_payment - .check_for_transaction(|txid| { - let get_tx_result = receiver.get_raw_transaction(txid); - match get_tx_result { - Ok(tx) => - Ok(Some(tx.transaction().expect("transaction should be decodable"))), - Err(_) => { - panic!("should be able to find the payjoin proposal broadcasted") - } - } - }) + let found_tx = receiver + .get_raw_transaction(broadcasted_transaction.compute_txid())? + .transaction() + .expect("transaction should be decodable"); + let settlement = monitoring_payment + .classify_tx(&found_tx) + .expect("the transaction settles the session"); + assert_eq!(settlement.outcome(), SettlementOutcome::Payjoin); + settlement + .close() .save(&recv_persister) - .expect("receiver should successfully monitor for the payment"); + .expect("receiver should successfully close the session"); - // Receiver session should have completed with a Success. let (_session, session_history) = replay_receiver_event_log(&recv_persister)?; assert_eq!( recv_persister.load().unwrap().last(), Some(payjoin::receive::v2::SessionEvent::Closed( - payjoin::receive::v2::SessionOutcome::Success( - broadcasted_transaction.compute_txid() - ) + payjoin::receive::v2::SessionOutcome::PayjoinProposalSent )), - "The last event of the persister should be a SessionOutcome::Success with the settlement txid", ); assert_eq!(session_history.status(), SessionStatus::Completed); Ok(()) @@ -695,31 +691,25 @@ mod integration { Amount::from_btc(0.0)? ); - // Receiver should be able to validate that the sender has broadcasted the Payjoin proposal. - monitoring_payment - .check_for_transaction(|txid| { - let get_tx_result = receiver.get_raw_transaction(txid); - match get_tx_result { - Ok(tx) => - Ok(Some(tx.transaction().expect("transaction should be decodable"))), - Err(_) => { - panic!("should be able to find the payjoin proposal broadcasted") - } - } - }) + let found_tx = receiver + .get_raw_transaction(broadcasted_transaction.compute_txid())? + .transaction() + .expect("transaction should be decodable"); + let settlement = monitoring_payment + .classify_tx(&found_tx) + .expect("the transaction settles the session"); + assert_eq!(settlement.outcome(), SettlementOutcome::Payjoin); + settlement + .close() .save(&recv_persister) - .expect("receiver should successfully monitor for the payment"); + .expect("receiver should successfully close the session"); - // Receiver session should have completed with a Success. let (_session, session_history) = replay_receiver_event_log(&recv_persister)?; assert_eq!( recv_persister.load().unwrap().last(), Some(payjoin::receive::v2::SessionEvent::Closed( - payjoin::receive::v2::SessionOutcome::Success( - broadcasted_transaction.compute_txid() - ) + payjoin::receive::v2::SessionOutcome::PayjoinProposalSent )), - "The last event of the persister should be a SessionOutcome::Success with the settlement txid", ); assert_eq!(session_history.status(), SessionStatus::Completed); Ok(()) @@ -765,30 +755,27 @@ mod integration { Amount::from_btc(0.0)? ); - // Receiver should be able to validate that the sender has broadcasted the fallback transaction. - // The check_for_transaction closure should be called twice: first for the Payjoin proposal, which will not be found, - // and then for the fallback transaction, which will be found.. - monitoring_payment - .check_for_transaction(|txid| { - let get_tx_result = receiver.get_raw_transaction(txid); - match get_tx_result { - Ok(tx) => - Ok(Some(tx.transaction().expect("transaction should be decodable"))), - Err(_) => Ok(None), - } - }) + let found_tx = receiver + .get_raw_transaction(broadcasted_transaction.compute_txid())? + .transaction() + .expect("transaction should be decodable"); + let settlement = monitoring_payment + .classify_tx(&found_tx) + .expect("the transaction settles the session"); + assert_eq!(settlement.outcome(), SettlementOutcome::Fallback); + settlement + .close() .save(&recv_persister) - .expect("receiver should successfully monitor for the payment"); + .expect("receiver should successfully close the session"); - // Receiver session should have completed with a Success and a fallback session - // outcome. let (_session, session_history) = replay_receiver_event_log(&recv_persister)?; assert_eq!( recv_persister.load().unwrap().last(), - Some(payjoin::receive::v2::SessionEvent::Closed(payjoin::receive::v2::SessionOutcome::FallbackBroadcasted)), - "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::PayjoinProposalSent + )), ); - assert_eq!(session_history.status(), SessionStatus::FallbackBroadcasted); + assert_eq!(session_history.status(), SessionStatus::Completed); Ok(()) } From 449c0ed4864d2fc63261a6188d2e0babc48a427c Mon Sep 17 00:00:00 2001 From: xstoicunicornx Date: Sat, 10 Oct 2026 21:06:51 +0000 Subject: [PATCH 3/5] Document terminal outcomes as protocol, not payment Every terminal outcome reads as a payment verdict today. Aborted says the payjoin "was not successful" when a fallback may well have been broadcast and confirmed, and PayjoinProposalSent still describes non-SegWit senders as unmonitorable, which classify_tx now makes untrue. Say what each one means. Aborted is that the session was cancelled or failed, or a transaction settled it before the receiver handed off its proposal, and the receiver closed it. That can happen after the proposal was posted, since Monitor can still cancel into PendingFallback and close from there. PayjoinProposalSent is that the receiver handed off the proposal and the wallet closed the session after settling its outcome, whichever transaction that was. Settlement also writes it for a PayjoinProposal session settled by its Payjoin, so a session closed this way without a posted proposal in its log was settled by its Payjoin; say so. Note on Success and FallbackBroadcasted that only the deprecated check_for_transaction writes them, so sessions settled through classify_tx and Settlement::close never carry them. SessionStatus::Completed needs the same treatment and matters more, since it is the end state of every successful session and wallet UIs read status(). Document it as the wallet having settled the outcome, without saying which transaction settled it, and document the remaining variants rather than leaving one commented among five bare ones. Record on Unrecognized that the receiver never writes it, and point to classify_tx, which reports such a spend without persisting it. The variant was reserved for a classifier that would have persisted its verdict. It stays because the enum is exhaustive, so removing it is a semver break. Note on SessionStatus::FallbackBroadcasted, as on its SessionOutcome, that only the deprecated check_for_transaction produces it. --- payjoin/src/core/receive/v2/session.rs | 37 +++++++++++++++++--------- 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/payjoin/src/core/receive/v2/session.rs b/payjoin/src/core/receive/v2/session.rs index 27e99a6cf..0354c4d9f 100644 --- a/payjoin/src/core/receive/v2/session.rs +++ b/payjoin/src/core/receive/v2/session.rs @@ -182,11 +182,17 @@ impl SessionHistory { #[derive(Debug, Clone, PartialEq, Eq)] #[non_exhaustive] pub enum SessionStatus { + /// The session is open and has not expired. Active, + /// The session expired before it was closed. Expired, + /// The session closed as [`SessionOutcome::Aborted`]. Failed, + /// The session closed after the Payjoin proposal was sent. Completed, + /// The session closed as [`SessionOutcome::FallbackBroadcasted`]. FallbackBroadcasted, + /// The session was cancelled or failed and holds a fallback transaction to broadcast. PendingFallback, } @@ -215,29 +221,36 @@ pub enum SessionEvent { Closed(SessionOutcome), } -/// Represents all possible outcomes for a closed Payjoin session +/// Represents all possible outcomes for a closed Payjoin session. +/// +/// Outcomes describe the protocol, not which transaction settled the payment. /// /// 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. A new /// unhandled variant _should_ error at compile-time. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub enum SessionOutcome { - /// Payjoin completed successfully: the transaction with this txid settled the session + /// The Payjoin transaction with this txid was found. + /// + /// Only produced by the deprecated [`Receiver::check_for_transaction`]. + /// + /// [`Receiver::check_for_transaction`]: super::Receiver::check_for_transaction Success(bitcoin::Txid), - /// Payjoin was not successful + /// The session ended without delivering the Payjoin proposal, or was cancelled. + /// + /// The fallback may still have paid the receiver. Aborted, - /// Fallback transaction was broadcasted + /// The fallback transaction was found. + /// + /// Only produced by the deprecated [`Receiver::check_for_transaction`]. + /// + /// [`Receiver::check_for_transaction`]: super::Receiver::check_for_transaction FallbackBroadcasted, - /// Payjoin proposal was sent, but its broadcast status cannot be tracked because - /// the sender is using non-SegWit inputs which will change the transaction ID - /// of the proposal + /// The Payjoin proposal was delivered to the sender. PayjoinProposalSent, - /// The contested outpoints were settled by an unrecognized transaction (neither the - /// Payjoin nor the fallback), identified by its txid. + /// The sender's inputs were spent by an unrecognized transaction with this txid. /// - /// NOTE: Nothing in this release produces this variant. It is reserved now because this enum is - /// deliberately exhaustive, so a new variant after 1.0 would be a semver-breaking API - /// change; naming it before the freeze lets the settlement classifier land additively. + /// NOTE: Nothing produces this variant. It is reserved because this enum is exhaustive. Unrecognized(bitcoin::Txid), } From 450521ebd4e82e19ae550df6d3e64ec36b3a0ddd Mon Sep 17 00:00:00 2001 From: xstoicunicornx Date: Mon, 12 Oct 2026 10:45:58 +0000 Subject: [PATCH 4/5] Expose settlement classification over FFI The receiver gained a classifier on every state with a fallback, and a Settlement that closes the session it classified, but bindings could still only call check_for_transaction, which probes two predicted txids and closes on the first match. Wallets built on the bindings had no way to apply their own confirmation bar or to recognize a Payjoin whose txid a non-SegWit sender changed. Expose classify_tx on every state from MaybeInputsOwned through Monitor, the same states that cancel into PendingFallback, through one macro like the cancel methods. Wallets are meant to classify every received transaction against each open session, so the binding needs it wherever core has it. Expose Settlement as an object with outcome and close, SettlementOutcome as a uniffi enum, and a SettlementCloseTransition to persist the close. A uniffi object has no constructor unless one is exported, so foreign code cannot make a Settlement either. Expose ReceiveSession::classify_tx as the top-level function classify_receiver_tx rather than a method on the ReceiveSession enum. uniffi-dart 0.2.1 renders methods only on objects, so an enum method would be missing from the Dart bindings, and uniffi-bindgen-react-native would render it as a static function taking the enum anyway. It decodes the transaction in every state, so bad bytes fail the same way whatever the session's state. Carry core's note on watching forwarded outputs over to substitute_receiver_script and Monitor. classify_tx takes consensus-encoded bytes like the rest of the binding, so it returns a TransactionParseError when they do not decode. The core SettlementOutcome is non_exhaustive; a verdict this binding does not know maps to Unrecognized rather than panicking. Remove check_for_transaction, with the TransactionFinder callback and MonitorTransition it needed, and proposal_txid_is_stable from every receiver state, rather than deprecating them as core does. payjoin-ffi is not yet 1.0, so it can drop them now, and uniffi cannot carry #[deprecated] into the bindings, so a deprecation would only be a doc note binding users may never see. Record the additions and removals under Unreleased in the FFI changelog, as the changelog check requires for a change to payjoin-ffi/src. Kotlin objects are AutoCloseable, whose close() drops the Rust handle, so rename Settlement.close to closeSession there, as uniffi.toml already does for the other types that export a close. Exercise the new API in every binding's tests. The v2 integration test saves the receiver's Monitor, classifies the broadcast Payjoin with Monitor.classify_tx and with classify_receiver_tx on the replayed session, and closes the session through the Settlement. A unit test checks that classify_receiver_tx returns nothing for a session without a fallback and rejects bytes that are not a transaction. --- payjoin-ffi/CHANGELOG.md | 18 ++ payjoin-ffi/csharp/IntegrationTests.cs | 23 +- payjoin-ffi/csharp/UnitTests.cs | 23 ++ .../test/test_payjoin_integration_test.dart | 35 ++- .../dart/test/test_payjoin_unit_test.dart | 42 +++ .../javascript/test/integration.test.ts | 44 +++- payjoin-ffi/javascript/test/unit.test.ts | 42 +++ .../org/payjoindevkit/IntegrationTests.kt | 51 +++- .../org/payjoindevkit/PersistenceTests.kt | 19 ++ .../kotlin/org/payjoindevkit/TestFixtures.kt | 7 + .../test/test_payjoin_integration_test.py | 23 +- .../python/test/test_payjoin_unit_test.py | 30 +++ payjoin-ffi/src/receive/error.rs | 8 + payjoin-ffi/src/receive/mod.rs | 246 +++++++++++------- payjoin-ffi/uniffi.toml | 1 + 15 files changed, 496 insertions(+), 116 deletions(-) diff --git a/payjoin-ffi/CHANGELOG.md b/payjoin-ffi/CHANGELOG.md index 578323a34..c3965431f 100644 --- a/payjoin-ffi/CHANGELOG.md +++ b/payjoin-ffi/CHANGELOG.md @@ -1,5 +1,23 @@ ## Unreleased +- Add settlement classification to every receiver state that holds a + fallback transaction, from `MaybeInputsOwned` through `Monitor`. Pass + every consensus-encoded transaction the wallet receives to `classify_tx` + on each open session, or to `classify_receiver_tx` with a session in any + state. It returns a `Settlement` whose `outcome` is a `SettlementOutcome` + of `Payjoin`, `Fallback` or `Unrecognized`, or none for a transaction that + spends none of the sender's original prevouts, and never reports `Payjoin` + before `PayjoinProposal`. `Settlement.close` ends the session once the + settlement meets the wallet's confirmation bar. In Kotlin, `close` is + `closeSession`. +- Remove `Monitor.check_for_transaction`, with its `TransactionFinder` + callback and `MonitorTransition`. It closed the session on first sight of + a predicted txid and never saw a spend that is neither the proposal nor + the fallback. Use `classify_tx` and `Settlement.close` instead. +- Remove `proposal_txid_is_stable` from every receiver state. It checked + only the sender's inputs, so a legacy or P2SH-wrapped receiver input could + change the txid even when it returned true. Use `classify_tx` instead. + ## [0.25.1] - Wraps payjoin 1.2.0 diff --git a/payjoin-ffi/csharp/IntegrationTests.cs b/payjoin-ffi/csharp/IntegrationTests.cs index 2d5d8cb68..a24e2d27d 100644 --- a/payjoin-ffi/csharp/IntegrationTests.cs +++ b/payjoin-ffi/csharp/IntegrationTests.cs @@ -796,7 +796,8 @@ public async Task TestIntegrationV2ToV2(TransitionMode mode, bool taproot) cancellationToken); var proposalResponseBuffer = await proposalResponse.Content.ReadAsByteArrayAsync(cancellationToken); - payjoinProposal.ProcessResponse(proposalResponseBuffer, proposalRequest.ClientResponse); + using var monitorTransition = payjoinProposal.ProcessResponse(proposalResponseBuffer, proposalRequest.ClientResponse); + using var monitor = monitorTransition.Save(recvPersister); // ******************************* // SENDER SIDE (FINALIZATION) @@ -855,6 +856,26 @@ public async Task TestIntegrationV2ToV2(TransitionMode mode, bool taproot) var finalHex = extractionDoc.RootElement.GetProperty("hex").GetString()!; RpcCall(sender, "sendrawtransaction", JsonSerializer.Serialize(finalHex)); + // ******************************* + // Inside the Receiver: + // Classify the broadcast Payjoin, then close the session through the + // settlement, as a wallet that replays its open sessions would. + var finalTx = Convert.FromHexString(finalHex); + using (var monitorSettlement = monitor.ClassifyTx(finalTx)) + { + Assert.NotNull(monitorSettlement); + Assert.Equal(SettlementOutcome.Payjoin, monitorSettlement!.Outcome()); + } + var replayed = PayjoinMethods.ReplayReceiverEventLog(recvPersister).State(); + Assert.IsType(replayed); + using var settlement = PayjoinMethods.ClassifyReceiverTx(replayed, finalTx); + Assert.NotNull(settlement); + Assert.Equal(SettlementOutcome.Payjoin, settlement!.Outcome()); + using var closeTransition = settlement.Close(); + closeTransition.Save(recvPersister); + Assert.True(recvPersister.Closed); + Assert.IsType(PayjoinMethods.ReplayReceiverEventLog(recvPersister).State()); + // ******************************* // VERIFY RESULTS // The broadcast transaction spends one input from each party into the diff --git a/payjoin-ffi/csharp/UnitTests.cs b/payjoin-ffi/csharp/UnitTests.cs index ffdd9961b..42e91875f 100644 --- a/payjoin-ffi/csharp/UnitTests.cs +++ b/payjoin-ffi/csharp/UnitTests.cs @@ -81,6 +81,29 @@ public void ReceiverPersistence() Assert.IsType(state); } + // A consensus-encoded transaction with one input and one output. It spends + // nothing a fresh session holds. + private static readonly byte[] UnrelatedTx = Convert.FromHexString( + "02000000010000000000000000000000000000000000000000000000000000000000000000" + + "0000000000ffffffff0100000000000000000000000000"); + + [Fact] + public void ClassifyReceiverTxWithoutFallback() + { + // A session that has not received the original has nothing to compare + // against, and bytes that are not a transaction are rejected whatever + // the session's state. + var persister = new InMemoryReceiverPersister(); + var ohttpKeys = OhttpKeys.Decode(OhttpKeysData); + var builder = new ReceiverBuilder("tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4", "https://example.com", ohttpKeys); + builder.Build().Save(persister); + var state = PayjoinMethods.ReplayReceiverEventLog(persister).State(); + + Assert.Null(PayjoinMethods.ClassifyReceiverTx(state, UnrelatedTx)); + Assert.Throws(() => + PayjoinMethods.ClassifyReceiverTx(state, new byte[] { 0 })); + } + [Fact] public void SenderPersistence() { diff --git a/payjoin-ffi/dart/test/test_payjoin_integration_test.dart b/payjoin-ffi/dart/test/test_payjoin_integration_test.dart index 43a91ec82..52c69b3c9 100644 --- a/payjoin-ffi/dart/test/test_payjoin_integration_test.dart +++ b/payjoin-ffi/dart/test/test_payjoin_integration_test.dart @@ -572,10 +572,12 @@ Future run_integration_v2_to_v2( headers: {"Content-Type": request_response.request.contentType}, body: request_response.request.body, ); - proposal.processResponse( - body: fallback_response.bodyBytes, - ohttpContext: request_response.clientResponse, - ); + payjoin.Monitor monitor = proposal + .processResponse( + body: fallback_response.bodyBytes, + ohttpContext: request_response.clientResponse, + ) + .save(persister: recv_persister); // ********************** // Inside the Sender: @@ -628,6 +630,31 @@ Future run_integration_v2_to_v2( )["hex"]; sender.call(method: "sendrawtransaction", params: [jsonEncode(final_tx_hex)]); + // ********************** + // Inside the Receiver: + // Classify the broadcast Payjoin, then close the session through the + // settlement, as a wallet that replays its open sessions would. + final final_tx = Uint8List.fromList(hex.decode(final_tx_hex as String)); + final monitor_settlement = monitor.classifyTx(tx: final_tx); + expect(monitor_settlement, isNotNull); + expect(monitor_settlement!.outcome(), payjoin.SettlementOutcome.payjoin); + final replayed = payjoin + .replayReceiverEventLog(persister: recv_persister) + .state(); + expect(replayed, isA()); + final settlement = payjoin.classifyReceiverTx( + session: replayed, + tx: final_tx, + ); + expect(settlement, isNotNull); + expect(settlement!.outcome(), payjoin.SettlementOutcome.payjoin); + settlement.close().save(persister: recv_persister); + expect(recv_persister.closed, isTrue); + expect( + payjoin.replayReceiverEventLog(persister: recv_persister).state(), + isA(), + ); + // Check resulting transaction and balances var decodedTx = jsonDecode( sender.call( diff --git a/payjoin-ffi/dart/test/test_payjoin_unit_test.dart b/payjoin-ffi/dart/test/test_payjoin_unit_test.dart index 3a16deb9d..98e8d95f5 100644 --- a/payjoin-ffi/dart/test/test_payjoin_unit_test.dart +++ b/payjoin-ffi/dart/test/test_payjoin_unit_test.dart @@ -6,6 +6,15 @@ import "package:payjoin/payjoin.dart" as payjoin; import "utils.dart"; +// A consensus-encoded transaction with one input and one output. It spends +// nothing a fresh session holds. +final unrelatedTx = Uint8List.fromList( + hex.decode( + "02000000010000000000000000000000000000000000000000000000000000000000000000" + "0000000000ffffffff0100000000000000000000000000", + ), +); + void main() { group('Test URIs', () { test('Test url userinfo rejection', () { @@ -91,6 +100,39 @@ void main() { ); }); + test("Test classify receiver tx without fallback", () { + // A session that has not received the original has nothing to compare + // against, and bytes that are not a transaction are rejected whatever + // the session's state. + var persister = InMemoryReceiverPersister(); + payjoin.ReceiverBuilder( + address: "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4", + directory: "https://example.com", + ohttpKeys: payjoin.OhttpKeys.decode( + bytes: Uint8List.fromList( + hex.decode( + "01001604ba48c49c3d4a92a3ad00ecc63a024da10ced02180c73ec12d8a7ad2cc91bb483824fe2bee8d28bfe2eb2fc6453bc4d31cd851e8a6540e86c5382af588d370957000400010003", + ), + ), + ), + ).build().save(persister: persister); + final state = payjoin + .replayReceiverEventLog(persister: persister) + .state(); + + expect( + payjoin.classifyReceiverTx(session: state, tx: unrelatedTx), + isNull, + ); + expect( + () => payjoin.classifyReceiverTx( + session: state, + tx: Uint8List.fromList([0]), + ), + throwsA(isA()), + ); + }); + test("Test sender persistence", () { var receiver_persister = InMemoryReceiverPersister(); var receiver = payjoin.ReceiverBuilder( diff --git a/payjoin-ffi/javascript/test/integration.test.ts b/payjoin-ffi/javascript/test/integration.test.ts index 1b091da5d..c5ae426cf 100644 --- a/payjoin-ffi/javascript/test/integration.test.ts +++ b/payjoin-ffi/javascript/test/integration.test.ts @@ -694,10 +694,9 @@ async function testIntegrationV2ToV2( body: requestResponse.request.body, }); const fallbackResponseBuffer = await fallbackResponse.arrayBuffer(); - proposal.processResponse( - fallbackResponseBuffer, - requestResponse.clientResponse, - ); + const monitor = proposal + .processResponse(fallbackResponseBuffer, requestResponse.clientResponse) + .save(recvPersister); let pollOutcome: | PJNested<"PollingForProposalTransitionOutcome", "Progress"> @@ -742,6 +741,43 @@ async function testIntegrationV2ToV2( const finalHex = extraction.hex as string; sender.call("sendrawtransaction", [JSON.stringify(finalHex)]); + // Classify the broadcast Payjoin, then close the session through the + // settlement, as a wallet that replays its open sessions would. + const finalTx = new Uint8Array(Buffer.from(finalHex, "hex")).buffer; + const monitorSettlement = monitor.classifyTx(finalTx); + assert( + monitorSettlement !== undefined, + "Monitor should classify the Payjoin", + ); + assert.strictEqual( + monitorSettlement.outcome(), + payjoin.SettlementOutcome.Payjoin, + "Monitor should recognize the Payjoin", + ); + const replayed = payjoin.replayReceiverEventLog(recvPersister).state(); + assert.strictEqual( + replayed.tag, + "Monitor", + "Session should replay to Monitor", + ); + const settlement = payjoin.classifyReceiverTx(replayed, finalTx); + assert( + settlement !== undefined, + "Replayed session should classify the Payjoin", + ); + assert.strictEqual( + settlement.outcome(), + payjoin.SettlementOutcome.Payjoin, + "Replayed session should recognize the Payjoin", + ); + settlement.close().save(recvPersister); + assert(recvPersister.closed, "Closing should close the persister"); + assert.strictEqual( + payjoin.replayReceiverEventLog(recvPersister).state().tag, + "Closed", + "Session should replay as closed", + ); + const decodedPsbt = JSON.parse( sender.call("decodepsbt", [JSON.stringify(finalPsbt)]), ); diff --git a/payjoin-ffi/javascript/test/unit.test.ts b/payjoin-ffi/javascript/test/unit.test.ts index 76341f77e..def3728aa 100644 --- a/payjoin-ffi/javascript/test/unit.test.ts +++ b/payjoin-ffi/javascript/test/unit.test.ts @@ -39,6 +39,16 @@ const OHTTP_KEYS = new Uint8Array([ 0x00, 0x03, ]).buffer; +// A consensus-encoded transaction with one input and one output. It spends +// nothing a fresh session holds. +const UNRELATED_TX = new Uint8Array( + Buffer.from( + "02000000010000000000000000000000000000000000000000000000000000000000000000" + + "0000000000ffffffff0100000000000000000000000000", + "hex", + ), +).buffer; + const ORIGINAL_PSBT = "cHNidP8BAHMCAAAAAY8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////AtyVuAUAAAAAF6kUHehJ8GnSdBUOOv6ujXLrWmsJRDCHgIQeAAAAAAAXqRR3QJbbz0hnQ8IvQ0fptGn+votneofTAAAAAAEBIKgb1wUAAAAAF6kU3k4ekGHKWRNbA1rV5tR5kEVDVNCHAQcXFgAUx4pFclNVgo1WWAdN1SYNX8tphTABCGsCRzBEAiB8Q+A6dep+Rz92vhy26lT0AjZn4PRLi8Bf9qoB/CMk0wIgP/Rj2PWZ3gEjUkTlhDRNAQ0gXwTO7t9n+V14pZ6oljUBIQMVmsAaoNWHVMS02LfTSe0e388LNitPa1UQZyOihY+FFgABABYAFEb2Giu6c4KO5YW0pfw3lGp9jMUUAAA="; @@ -120,6 +130,38 @@ function runUnitTests(name: string, payjoin: typeof nodejsPayjoin) { ); }); + test("classify receiver tx without fallback", () => { + // A session that has not received the original has nothing to + // compare against, and bytes that are not a transaction are + // rejected whatever the session's state. + const persister = new InMemoryReceiverPersister(); + new payjoin.ReceiverBuilder( + "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4", + "https://example.com", + payjoin.OhttpKeys.decode(OHTTP_KEYS), + ) + .build() + .save(persister); + const state = payjoin.replayReceiverEventLog(persister).state(); + + assert.strictEqual( + payjoin.classifyReceiverTx(state, UNRELATED_TX), + undefined, + "An Initialized session should classify nothing", + ); + assert.throws( + () => + payjoin.classifyReceiverTx( + state, + new Uint8Array([0]).buffer, + ), + (e: unknown) => + payjoin.TransactionParseError.InvalidTransaction.instanceOf( + e, + ), + ); + }); + test("sender persistence", () => { const persister = new InMemoryReceiverPersister(); const address = "2MuyMrZHkbHbfjudmKUy45dU4P17pjG2szK"; diff --git a/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/IntegrationTests.kt b/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/IntegrationTests.kt index 3816ff8c5..0a1b25f7c 100644 --- a/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/IntegrationTests.kt +++ b/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/IntegrationTests.kt @@ -164,23 +164,28 @@ class IntegrationTests { cancelReceiver(payjoinProposal, recvPersister) return } - payjoinProposal.createPostRequest(relay).useDisposable { posted -> + val monitor = payjoinProposal.createPostRequest(relay).useDisposable { posted -> val body = http.post(posted.request) payjoinProposal.processResponse(body, posted.clientResponse).use { transition -> - transition.save(recvPersister).use { } + transition.save(recvPersister) } } + monitor.use { + // ********************** + // Inside the Sender: + val psbtBase64 = waitForSenderProposal(pollingForProposal, sendPersister, http, relay) + val finalTx = finishPayjoin( + senderRpc, + receiverRpc, + psbtBase64, + senderOutpoints, + receiverOutpoints, + ) - // ********************** - // Inside the Sender: - val psbtBase64 = waitForSenderProposal(pollingForProposal, sendPersister, http, relay) - finishPayjoin( - senderRpc, - receiverRpc, - psbtBase64, - senderOutpoints, - receiverOutpoints, - ) + // ********************** + // Inside the Receiver: + settleReceiver(monitor, recvPersister, finalTx) + } } } } @@ -391,7 +396,7 @@ class IntegrationTests { psbtBase64: String, senderOutpoints: Set, receiverOutpoints: Set, - ) { + ): ByteArray { val payjoinPsbt = Json.parseToJsonElement(rpc(senderRpc, "walletprocesspsbt", jstr(psbtBase64))) .jsonObject.getValue("psbt").jsonPrimitive.content val finalPsbt = Json.parseToJsonElement(rpc(senderRpc, "finalizepsbt", jstr(payjoinPsbt), "false")) @@ -426,6 +431,26 @@ class IntegrationTests { assertEquals(100.0 - networkFees, receiverPending, 1e-6) val senderBalance = Json.parseToJsonElement(rpc(senderRpc, "getbalance")).jsonPrimitive.double assertEquals(0.0, senderBalance, 1e-6) + return HexFormat.of().parseHex(finalTxHex) + } + + /** + * Classifies the broadcast Payjoin, then closes the session through the settlement, as a + * wallet that replays its open sessions would. + */ + private fun settleReceiver(monitor: Monitor, recvPersister: InMemoryReceiverPersister, finalTx: ByteArray) { + assertNotNull(monitor.classifyTx(finalTx), "Monitor should classify the Payjoin").use { settlement -> + assertEquals(SettlementOutcome.PAYJOIN, settlement.outcome()) + } + val replayed = replayReceiverEventLog(recvPersister).state() + assertIs(replayed) + assertNotNull(classifyReceiverTx(replayed, finalTx), "Replayed session should classify the Payjoin") + .use { settlement -> + assertEquals(SettlementOutcome.PAYJOIN, settlement.outcome()) + settlement.closeSession().use { it.save(recvPersister) } + } + assertTrue(recvPersister.closed, "closing should close the persister") + assertIs(replayReceiverEventLog(recvPersister).state()) } } diff --git a/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/PersistenceTests.kt b/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/PersistenceTests.kt index 2cbfcd03f..34c857e99 100644 --- a/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/PersistenceTests.kt +++ b/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/PersistenceTests.kt @@ -1,8 +1,10 @@ package org.payjoindevkit import kotlin.test.Test +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertIs +import kotlin.test.assertNull import kotlin.test.assertTrue import kotlinx.coroutines.test.runTest @@ -21,6 +23,23 @@ class PersistenceTests { assertTrue(persister.closed) } + @Test + fun classifyReceiverTxWithoutFallback() { + // A session that has not received the original has nothing to compare against, and bytes + // that are not a transaction are rejected whatever the session's state. + val persister = InMemoryReceiverPersister() + val ohttpKeys = OhttpKeys.decode(ohttpKeysData) + ReceiverBuilder("tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4", "https://example.com", ohttpKeys) + .build() + .save(persister) + val state = replayReceiverEventLog(persister).state() + + assertNull(classifyReceiverTx(state, unrelatedTx)) + assertFailsWith { + classifyReceiverTx(state, byteArrayOf(0)) + } + } + @Test fun senderPersistence() { val receiverPersister = InMemoryReceiverPersister() diff --git a/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/TestFixtures.kt b/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/TestFixtures.kt index ff1351dda..611ad20b5 100644 --- a/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/TestFixtures.kt +++ b/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/TestFixtures.kt @@ -6,3 +6,10 @@ internal val ohttpKeysData: ByteArray = HexFormat.of().parseHex( "01001604ba48c49c3d4a92a3ad00ecc63a024da10ced02180c73ec12d8a7ad2cc91bb483824fe2bee8d28bfe2eb2fc6453bc4d31cd851e8a6540e86c5382af588d370957000400010003", ) + +/** A consensus-encoded transaction with one input and one output. It spends nothing a fresh session holds. */ +internal val unrelatedTx: ByteArray = + HexFormat.of().parseHex( + "02000000010000000000000000000000000000000000000000000000000000000000000000" + + "0000000000ffffffff0100000000000000000000000000", + ) diff --git a/payjoin-ffi/python/test/test_payjoin_integration_test.py b/payjoin-ffi/python/test/test_payjoin_integration_test.py index 689e4dc12..e0f42215b 100644 --- a/payjoin-ffi/python/test/test_payjoin_integration_test.py +++ b/payjoin-ffi/python/test/test_payjoin_integration_test.py @@ -419,9 +419,9 @@ async def _run_integration_v2_to_v2(self, mode: TransitionMode): headers={"Content-Type": request_recv.request.content_type}, content=request_recv.request.body, ) - payjoin_proposal.process_response( + monitor: Monitor = payjoin_proposal.process_response( response.content, request_recv.client_response - ) + ).save(recv_persister) # ********************** # Inside the Sender: @@ -457,6 +457,25 @@ async def _run_integration_v2_to_v2(self, mode: TransitionMode): )["hex"] self.sender.call("sendrawtransaction", [json.dumps(final_tx_hex)]) + # ********************** + # Inside the Receiver: + # Classify the broadcast Payjoin, then close the session through the + # settlement, as a wallet that replays its open sessions would. + final_tx = bytes.fromhex(final_tx_hex) + monitor_settlement = monitor.classify_tx(final_tx) + self.assertIsNotNone(monitor_settlement) + self.assertEqual(monitor_settlement.outcome(), SettlementOutcome.PAYJOIN) + replayed = replay_receiver_event_log(recv_persister).state() + self.assertTrue(replayed.is_MONITOR()) + settlement = classify_receiver_tx(replayed, final_tx) + self.assertIsNotNone(settlement) + self.assertEqual(settlement.outcome(), SettlementOutcome.PAYJOIN) + settlement.close().save(recv_persister) + self.assertTrue(recv_persister.closed) + self.assertTrue( + replay_receiver_event_log(recv_persister).state().is_CLOSED() + ) + # Check resulting transaction and balances decoded_psbt = json.loads( self.sender.call("decodepsbt", [json.dumps(final_psbt)]) diff --git a/payjoin-ffi/python/test/test_payjoin_unit_test.py b/payjoin-ffi/python/test/test_payjoin_unit_test.py index 2302c49d2..eab41ad33 100644 --- a/payjoin-ffi/python/test/test_payjoin_unit_test.py +++ b/payjoin-ffi/python/test/test_payjoin_unit_test.py @@ -60,6 +60,36 @@ def test_receiver_persistence(self): self.assertTrue(result.state().is_INITIALIZED()) +# A consensus-encoded transaction with one input and one output. It spends +# nothing a fresh session holds. +UNRELATED_TX = bytes.fromhex( + "02000000010000000000000000000000000000000000000000000000000000000000000000" + "0000000000ffffffff0100000000000000000000000000" +) + + +class TestClassifyReceiverTx(unittest.TestCase): + def test_classify_receiver_tx_without_fallback(self): + # A session that has not received the original has nothing to compare + # against, and bytes that are not a transaction are rejected whatever + # the session's state. + persister = InMemoryReceiverPersister() + payjoin.ReceiverBuilder( + "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4", + "https://example.com", + payjoin.OhttpKeys.decode( + bytes.fromhex( + "01001604ba48c49c3d4a92a3ad00ecc63a024da10ced02180c73ec12d8a7ad2cc91bb483824fe2bee8d28bfe2eb2fc6453bc4d31cd851e8a6540e86c5382af588d370957000400010003" + ) + ), + ).build().save(persister) + state = payjoin.replay_receiver_event_log(persister).state() + + self.assertIsNone(payjoin.classify_receiver_tx(state, UNRELATED_TX)) + with self.assertRaises(payjoin.TransactionParseError): + payjoin.classify_receiver_tx(state, b"\x00") + + class TestSenderPersistence(unittest.TestCase): def test_sender_persistence(self): # Create a receiver to just get the pj uri diff --git a/payjoin-ffi/src/receive/error.rs b/payjoin-ffi/src/receive/error.rs index 8d100b51a..a8a038d7d 100644 --- a/payjoin-ffi/src/receive/error.rs +++ b/payjoin-ffi/src/receive/error.rs @@ -272,6 +272,14 @@ impl From for InputPairError { fn from(value: FfiValidationError) -> Self { InputPairError::FfiValidation(value) } } +/// Error decoding a consensus-encoded transaction passed across the FFI boundary. +#[derive(Debug, thiserror::Error, uniffi::Error)] +pub enum TransactionParseError { + /// The provided bytes are not a valid consensus-encoded transaction. + #[error("Invalid transaction: {0}")] + InvalidTransaction(String), +} + /// Error that may occur when a receiver event log is replayed #[derive(Debug, thiserror::Error, uniffi::Object)] #[uniffi::export(Debug, Display)] diff --git a/payjoin-ffi/src/receive/mod.rs b/payjoin-ffi/src/receive/mod.rs index 1688f31a3..02431cd77 100644 --- a/payjoin-ffi/src/receive/mod.rs +++ b/payjoin-ffi/src/receive/mod.rs @@ -4,7 +4,7 @@ use std::sync::{Arc, RwLock}; pub use error::{ AddressParseError, BuildReceiverError, CoinSelectionError, InputContributionError, InputPairError, JsonReply, OutputSubstitutionError, ProtocolError, PsbtInputError, - ReceiverCreateRequestError, ReceiverError, SessionError, + ReceiverCreateRequestError, ReceiverError, SessionError, TransactionParseError, }; use payjoin::bitcoin::consensus::Decodable; use payjoin::bitcoin::psbt::Psbt; @@ -192,6 +192,40 @@ macro_rules! impl_pending_fallback_cancel_for_receiver { }; } +fn decode_transaction(tx: &[u8]) -> Result { + payjoin::bitcoin::Transaction::consensus_decode(&mut &tx[..]) + .map_err(|e| TransactionParseError::InvalidTransaction(e.to_string())) +} + +macro_rules! impl_settlement_classification_for_receiver { + ($ty:ident) => { + #[uniffi::export] + impl $ty { + /// Classify a consensus-encoded transaction against this session's + /// Payjoin proposal and fallback. + /// + /// Call this with each transaction the wallet receives. Signatures + /// are not verified, so pass only transactions seen on the network. + /// If several transactions spend the sender's inputs, classify the + /// one with the most confirmations. + /// + /// Returns `None` if `tx` spends none of the sender's original + /// inputs. Otherwise returns a [`Settlement`] with outcome + /// [`SettlementOutcome::Fallback`] if `tx` is the fallback, + /// [`SettlementOutcome::Payjoin`] if it is the proposal, or + /// [`SettlementOutcome::Unrecognized`] otherwise. States before + /// [`PayjoinProposal`] never return [`SettlementOutcome::Payjoin`]. + pub fn classify_tx( + &self, + tx: Vec, + ) -> Result>, TransactionParseError> { + let tx = decode_transaction(&tx)?; + Ok(self.0.classify_tx(&tx).map(|settlement| Arc::new(settlement.into()))) + } + } + }; +} + impl_terminal_cancel_for_receiver!(Initialized); impl_terminal_cancel_for_receiver!(UncheckedOriginalPayload); impl_pending_fallback_cancel_for_receiver!(MaybeInputsOwned); @@ -204,6 +238,16 @@ impl_pending_fallback_cancel_for_receiver!(ProvisionalProposal); impl_pending_fallback_cancel_for_receiver!(PayjoinProposal); impl_pending_fallback_cancel_for_receiver!(Monitor); +impl_settlement_classification_for_receiver!(MaybeInputsOwned); +impl_settlement_classification_for_receiver!(MaybeInputsSeen); +impl_settlement_classification_for_receiver!(OutputsUnknown); +impl_settlement_classification_for_receiver!(WantsOutputs); +impl_settlement_classification_for_receiver!(WantsInputs); +impl_settlement_classification_for_receiver!(WantsFeeRange); +impl_settlement_classification_for_receiver!(ProvisionalProposal); +impl_settlement_classification_for_receiver!(PayjoinProposal); +impl_settlement_classification_for_receiver!(Monitor); + #[uniffi::export] impl HasReplyableError { /// Cancel the Payjoin session without posting the receiver error response. @@ -324,6 +368,36 @@ impl ReplayResult { pub fn session_history(&self) -> ReceiverSessionHistory { self.session_history.clone() } } +/// Classify a consensus-encoded transaction against a receiver session. +/// +/// Returns the result of `classify_tx` for states that hold a fallback, and +/// `None` for all other states. +#[uniffi::export] +pub fn classify_receiver_tx( + session: ReceiveSession, + tx: Vec, +) -> Result>, TransactionParseError> { + match session { + ReceiveSession::MaybeInputsOwned { inner } => inner.classify_tx(tx), + ReceiveSession::MaybeInputsSeen { inner } => inner.classify_tx(tx), + ReceiveSession::OutputsUnknown { inner } => inner.classify_tx(tx), + ReceiveSession::WantsOutputs { inner } => inner.classify_tx(tx), + ReceiveSession::WantsInputs { inner } => inner.classify_tx(tx), + ReceiveSession::WantsFeeRange { inner } => inner.classify_tx(tx), + ReceiveSession::ProvisionalProposal { inner } => inner.classify_tx(tx), + ReceiveSession::PayjoinProposal { inner } => inner.classify_tx(tx), + ReceiveSession::Monitor { inner } => inner.classify_tx(tx), + ReceiveSession::Initialized { .. } + | ReceiveSession::UncheckedOriginalPayload { .. } + | ReceiveSession::HasReplyableError { .. } + | ReceiveSession::ReceiverPendingFallback { .. } + | ReceiveSession::Closed { .. } => { + decode_transaction(&tx)?; + Ok(None) + } + } +} + #[uniffi::export] pub fn replay_receiver_event_log( persister: Arc, @@ -1277,6 +1351,9 @@ impl WantsOutputs { /// Substitute the receiver output script with the provided script. /// + /// If `output_script` is not owned by the receiver's wallet, watch it to + /// find the Payjoin proposal when it settles. + /// /// Returns an updated [`WantsOutputs`] with the substituted output. pub fn substitute_receiver_script( &self, @@ -1834,29 +1911,85 @@ impl HasReplyableError { } } -#[uniffi::export(with_foreign)] -pub trait TransactionFinder: Send + Sync { - fn callback(&self, txid: String) -> Result>, ForeignError>; +/// Typestate for a Payjoin proposal that has been posted to the sender. +/// +/// Call [`Monitor::classify_tx`] with each transaction the wallet receives, and +/// [`Settlement::close`] the returned settlement once it has enough +/// confirmations. If nothing settles the session before it expires, cancel it +/// and broadcast the fallback. +#[derive(uniffi::Object)] +pub struct Monitor(pub payjoin::receive::v2::Receiver); + +impl From> for Monitor { + fn from(value: payjoin::receive::v2::Receiver) -> Self { + Self(value) + } } -#[allow(clippy::type_complexity)] +/// Which transaction spent the sender's original inputs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum SettlementOutcome { + /// The Payjoin proposal. + Payjoin, + /// The fallback transaction. + Fallback, + /// Neither the Payjoin proposal nor the fallback, e.g. a fee bump or a + /// sender double-spend. + Unrecognized, +} + +impl From for SettlementOutcome { + fn from(value: payjoin::receive::v2::SettlementOutcome) -> Self { + use payjoin::receive::v2::SettlementOutcome as Core; + + match value { + Core::Payjoin => SettlementOutcome::Payjoin, + Core::Fallback => SettlementOutcome::Fallback, + Core::Unrecognized => SettlementOutcome::Unrecognized, + // The core enum is non_exhaustive. + _ => SettlementOutcome::Unrecognized, + } + } +} + +/// A transaction spending the sender's original inputs, as classified by +/// `classify_tx`. #[derive(uniffi::Object)] -pub struct MonitorTransition( +pub struct Settlement(payjoin::receive::v2::Settlement); + +impl From for Settlement { + fn from(value: payjoin::receive::v2::Settlement) -> Self { Self(value) } +} + +#[uniffi::export] +impl Settlement { + /// Returns which transaction settled the session. + pub fn outcome(&self) -> SettlementOutcome { self.0.outcome().into() } + + /// Close the Payjoin session. + /// + /// Call this once the settlement has enough confirmations for the wallet. + /// + /// Returns a [`SettlementCloseTransition`] that, once successfully + /// persisted, closes the receiver session. + pub fn close(&self) -> SettlementCloseTransition { + SettlementCloseTransition(Arc::new(RwLock::new(Some(self.0.clone().close())))) + } +} + +/// A transition produced by closing a settled session. +#[derive(uniffi::Object)] +#[allow(clippy::type_complexity)] +pub struct SettlementCloseTransition( Arc< RwLock< - Option< - payjoin::persist::MaybeFatalOrSuccessTransition< - payjoin::receive::v2::SessionEvent, - payjoin::receive::v2::Receiver, - payjoin::receive::Error, - >, - >, + Option>, >, >, ); #[uniffi::export] -impl MonitorTransition { +impl SettlementCloseTransition { pub fn save( &self, persister: Arc, @@ -1866,7 +1999,9 @@ impl MonitorTransition { let value = inner.take().expect("Already saved or moved"); - value.save(&adapter).map_err(ReceiverPersistedError::from)?; + value + .save(&adapter) + .map_err(|e| ReceiverPersistedError::from(ImplementationError::new(e)))?; Ok(()) } @@ -1880,87 +2015,14 @@ impl MonitorTransition { inner.take().expect("Already saved or moved") }; - value.save_async(&adapter).await.map_err(ReceiverPersistedError::from)?; + value + .save_async(&adapter) + .await + .map_err(|e| ReceiverPersistedError::from(ImplementationError::new(e)))?; Ok(()) } } -#[derive(uniffi::Object)] -pub struct Monitor(pub payjoin::receive::v2::Receiver); - -impl From> for Monitor { - fn from(value: payjoin::receive::v2::Receiver) -> Self { - Self(value) - } -} - -fn try_deserialize_tx( - buf: Vec, -) -> Result { - payjoin::bitcoin::transaction::Transaction::consensus_decode(&mut buf.as_slice()) - .map_err(|e| ForeignError::InternalError(e.to_string())) -} - -#[uniffi::export] -impl Monitor { - /// Check the network for the payjoin or fallback transaction via the - /// `find_transaction` callback. - /// - /// Returns a [`MonitorTransition`] that, once persisted, completes - /// the session if a transaction is found. - #[allow(deprecated)] - pub fn check_for_transaction( - &self, - find_transaction: Arc, - ) -> MonitorTransition { - MonitorTransition(Arc::new(RwLock::new(Some(self.0.clone().check_for_transaction( - |txid| { - find_transaction - .callback(txid.to_string()) - .and_then(|buf| buf.map(try_deserialize_tx).transpose()) - .map_err(|e| ImplementationError::new(e).into()) - }, - ))))) - } -} - -macro_rules! impl_proposal_txid_is_stable { - ($($state:ty),* $(,)?) => { - $( - #[uniffi::export] - impl $state { - /// Whether the payjoin proposal's transaction ID is knowable in advance. - /// - /// If every sender input is native SegWit (empty script_sig), the - /// transaction ID computed from the unsigned proposal remains valid once - /// the sender re-signs, so the receiver can record it (e.g. to reconcile - /// an incoming payment) and monitor the network for it. If any sender - /// input finalizes with a non-empty script_sig — legacy inputs, but also - /// P2SH-wrapped SegWit — the sender's final script_sig changes the - /// transaction ID: any ID derived from the proposal before the sender - /// signs will never appear on the network, and monitoring concludes the - /// session without checking. - #[allow(deprecated)] - pub fn proposal_txid_is_stable(&self) -> bool { - self.0.proposal_txid_is_stable() - } - } - )* - }; -} - -impl_proposal_txid_is_stable!( - MaybeInputsOwned, - MaybeInputsSeen, - OutputsUnknown, - WantsOutputs, - WantsInputs, - WantsFeeRange, - ProvisionalProposal, - PayjoinProposal, - Monitor, -); - #[derive(uniffi::Object)] #[allow(clippy::type_complexity)] pub struct PendingFallbackTransition( diff --git a/payjoin-ffi/uniffi.toml b/payjoin-ffi/uniffi.toml index f2be18d49..c28dc9534 100644 --- a/payjoin-ffi/uniffi.toml +++ b/payjoin-ffi/uniffi.toml @@ -7,6 +7,7 @@ cdylib_name = "payjoin_ffi" [bindings.kotlin.rename] "ReceiverPendingFallback.close" = "closeSession" "SenderPendingFallback.close" = "closeSession" +"Settlement.close" = "closeSession" "JsonReceiverSessionPersister.close" = "closeSession" "JsonReceiverSessionPersisterAsync.close" = "closeSession" "JsonSenderSessionPersister.close" = "closeSession" From 83821b161b92d214686052557d5562e1cd7b157a Mon Sep 17 00:00:00 2001 From: xstoicunicornx Date: Mon, 12 Oct 2026 10:45:58 +0000 Subject: [PATCH 5/5] Settle CLI receiver sessions with classify_tx After posting its proposal the receiver polled the mempool for a predicted txid for a few seconds and closed on first sight. It closed sessions with non-SegWit senders without checking at all. Have resume check the wallet once for each open receiver session, in any state, before driving it. Every wallet transaction paying the session's address is classified against the session. The CLI never substitutes the receiver output, so both the fallback and the Payjoin pay that address. A session settled with one confirmation is closed. A session settled with fewer is left open without advancing its protocol. Otherwise the session is driven as before and stops at Monitor until a later resume finds its settlement. bitcoind-async-client has no wrapper for listreceivedbyaddress, so enable its raw_rpc feature and call it directly. Stop printing the proposal txid for the user to watch. It was the unsigned proposal's, which is wrong for non-SegWit senders. --- payjoin-cli/CHANGELOG.md | 6 + payjoin-cli/Cargo.toml | 2 +- payjoin-cli/src/app/v2/mod.rs | 211 +++++++++++++++++++++------------- payjoin-cli/src/app/wallet.rs | 55 ++++++--- payjoin-cli/src/cli/mod.rs | 2 +- payjoin-cli/tests/e2e.rs | 172 +++++++++++++++++++++++++++ 6 files changed, 351 insertions(+), 97 deletions(-) diff --git a/payjoin-cli/CHANGELOG.md b/payjoin-cli/CHANGELOG.md index be97ca13f..8bba03b1b 100644 --- a/payjoin-cli/CHANGELOG.md +++ b/payjoin-cli/CHANGELOG.md @@ -5,6 +5,12 @@ - Fix panic when spending taproot UTXOs. They are now contributed as key-path spends with an assumed default input weight. - Any unsupported coin is simply skipped instead of crashing the cli. +- `resume` checks the wallet once for transactions settling each open receiver + session, in any state, and closes a session once its settling transaction + has one confirmation. It reports whether the Payjoin, the fallback, or + another transaction settled it. Sessions with non-SegWit senders are now + tracked too. The receiver no longer polls the mempool after posting the + proposal. ## 1.0.0 diff --git a/payjoin-cli/Cargo.toml b/payjoin-cli/Cargo.toml index a35391de4..ab9b6317d 100644 --- a/payjoin-cli/Cargo.toml +++ b/payjoin-cli/Cargo.toml @@ -27,7 +27,7 @@ v2 = ["payjoin/v2", "payjoin/io"] ahash = "0.7.8" anyhow = "1.0.103" async-trait = "0.1.89" -bitcoind-async-client = "0.16.1" +bitcoind-async-client = { version = "0.16.1", features = ["raw_rpc"] } clap = { version = "4.5.45", features = ["derive"] } config = "0.15.17" dirs = "6.0.0" diff --git a/payjoin-cli/src/app/v2/mod.rs b/payjoin-cli/src/app/v2/mod.rs index 051622e07..f6ba7444a 100644 --- a/payjoin-cli/src/app/v2/mod.rs +++ b/payjoin-cli/src/app/v2/mod.rs @@ -3,15 +3,15 @@ use std::sync::Arc; use anyhow::{anyhow, Context, Result}; use payjoin::bitcoin::consensus::encode::serialize_hex; -use payjoin::bitcoin::{Amount, FeeRate, Transaction}; +use payjoin::bitcoin::{Address, Amount, FeeRate, Transaction, Txid}; use payjoin::persist::{OptionalTransitionOutcome, SessionPersister}; use payjoin::receive::mark_checklist; use payjoin::receive::v2::{ replay_event_log as replay_receiver_event_log, HasReplyableError, Initialized, - MaybeInputsOwned, MaybeInputsSeen, Monitor, OutputsUnknown, PayjoinProposal, + MaybeInputsOwned, MaybeInputsSeen, OutputsUnknown, PayjoinProposal, PendingFallback as ReceiverPendingFallback, ProvisionalProposal, ReceiveSession, Receiver, - ReceiverBuilder, SessionOutcome as ReceiverSessionOutcome, UncheckedOriginalPayload, - WantsFeeRange, WantsInputs, WantsOutputs, + ReceiverBuilder, SessionOutcome as ReceiverSessionOutcome, Settlement, SettlementOutcome, + UncheckedOriginalPayload, WantsFeeRange, WantsInputs, WantsOutputs, }; use payjoin::send::v2::{ replay_event_log as replay_sender_event_log, PendingFallback as SenderPendingFallback, @@ -40,6 +40,27 @@ const W_STATUS: usize = 15; /// misbehaving directory or relay is not hammered in a tight loop. const TRANSIENT_RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(5); +/// Confirmations required before a receiver session is closed. +const SETTLEMENT_CONFIRMATIONS: i64 = 1; + +/// A wallet transaction that settles a receiver session. +struct FoundSettlement { + settlement: Settlement, + txid: Txid, + /// Negative for a transaction that conflicts with a confirmed one. + confirmations: i64, +} + +/// The result of checking the wallet for a receiver session's settlement. +enum SettlementCheck { + /// No wallet transaction settles the session. + Unsettled, + /// A settling transaction has fewer than `SETTLEMENT_CONFIRMATIONS`. + Confirming, + /// The session was closed. + Closed, +} + /// A request-construction error that can report whether it was caused by /// session expiry. Implemented by the sender/receiver request-building error /// types so `post_via_relay` can hand expiry back to the caller (which owns the @@ -106,16 +127,16 @@ impl StatusText for ReceiveSession { ReceiveSession::PayjoinProposal(_) => "Payjoin proposal sent", ReceiveSession::HasReplyableError(_) => "Session failure, waiting to post error response", - ReceiveSession::Monitor(_) => "Monitoring payjoin proposal", + ReceiveSession::Monitor(_) => "Payjoin proposal sent, awaiting settlement", 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 proposal was broadcasted", ReceiverSessionOutcome::FallbackBroadcasted => "Fallback broadcasted", ReceiverSessionOutcome::PayjoinProposalSent => - "Payjoin proposal sent, skipping monitoring as the sender is spending non-SegWit inputs", - ReceiverSessionOutcome::Unrecognized(_) => - "Settled by an unrecognized transaction", + "Payjoin proposal sent, settlement no longer tracked", + ReceiverSessionOutcome::Unrecognized(_) => "Settled by an unrecognized transaction", }, } } @@ -367,7 +388,9 @@ impl AppTrait for App { res = self.process_receiver_session( ReceiveSession::Initialized(session.clone()), &persister, - ) => res?, + ) => { + res?; + } _ = interrupt.changed() => { let session_id = persister.session_id(); persister.print(format_args!( @@ -396,19 +419,21 @@ impl AppTrait for App { return Ok(()); } - let mut tasks: Vec<((Role, SessionId), tokio::task::JoinHandle>)> = Vec::new(); + // Each task returns whether its session finished. + let mut tasks: Vec<((Role, SessionId), tokio::task::JoinHandle>)> = Vec::new(); // Process receiver sessions for session_id in recv_session_ids { let self_clone = self.clone(); let recv_persister = ReceiverPersister::from_id(self.db.clone(), session_id.clone()); match replay_receiver_event_log(&recv_persister) { - Ok((receiver_state, _)) => { + Ok((receiver_state, history)) => { + let address = history.pj_uri().address().clone(); tasks.push(( (Role::Receiver, session_id), tokio::spawn(async move { self_clone - .process_receiver_session(receiver_state, &recv_persister) + .resume_receiver_session(receiver_state, &address, &recv_persister) .await }), )); @@ -437,7 +462,10 @@ impl AppTrait for App { tasks.push(( (Role::Sender, session_id), tokio::spawn(async move { - self_clone.process_sender_session(sender_state, &sender_persister).await + self_clone + .process_sender_session(sender_state, &sender_persister) + .await + .map(|()| true) }), )); } @@ -462,9 +490,11 @@ impl AppTrait for App { for ((role, id), task) in tasks { match task.await { - Ok(Ok(())) => { + Ok(Ok(true)) => { print_session(role, &id, "Session completed."); } + // The session already printed what it awaits. + Ok(Ok(false)) => {} Ok(Err(e)) => { print_session(role, &id, format_args!("Session error: {e:#}")); } @@ -931,15 +961,17 @@ impl App { } } - /// Drive the receiver state machine until the session terminates. Each - /// step function performs one state transition and returns the next - /// state; the driver stops on the terminal `Closed` and - /// `PendingFallback` states. + /// Drive the receiver state machine until the session terminates or awaits + /// settlement. Each step function performs one state transition and + /// returns the next state; the driver stops on the terminal `Closed` and + /// `PendingFallback` states, and on `Monitor`. + /// + /// Returns `false` if the session stopped on `Monitor`. async fn process_receiver_session( &self, mut session: ReceiveSession, persister: &ReceiverPersister, - ) -> Result<()> { + ) -> Result { loop { session = match session { ReceiveSession::Initialized(proposal) => @@ -964,18 +996,20 @@ impl App { self.send_payjoin_proposal(proposal, persister).await?, ReceiveSession::HasReplyableError(error) => self.handle_error(error, persister).await?, - ReceiveSession::Monitor(proposal) => { - self.monitor_payjoin_proposal(proposal, persister).await?; - return Ok(()); + ReceiveSession::Monitor(_) => { + persister.print( + "Awaiting settlement. Run `payjoin-cli resume` to check the wallet again.", + ); + return Ok(false); } ReceiveSession::PendingFallback(_) => { let id = persister.session_id(); persister.print(format_args!( "Session was cancelled. Run `payjoin-cli cancel {id}` to cancel and broadcast the fallback transaction." )); - return Ok(()); + return Ok(true); } - ReceiveSession::Closed(_) => return Ok(()), + ReceiveSession::Closed(_) => return Ok(true), }; } } @@ -1146,13 +1180,9 @@ impl App { return Ok(ReceiveSession::Closed(ReceiverSessionOutcome::Aborted)); } }; - let payjoin_psbt = proposal.psbt().clone(); match proposal.process_response(&res.bytes().await?, ohttp_ctx).save(persister) { Ok(session) => { - persister.print(format_args!( - "Response successful. Watch mempool for successful Payjoin. TXID: {}", - payjoin_psbt.extract_tx_unchecked_fee_rate().compute_txid() - )); + persister.print("Response successful."); Ok(ReceiveSession::Monitor(session)) } Err(e) if e.is_transient() => { @@ -1165,62 +1195,79 @@ 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. - #[allow(deprecated)] - async fn monitor_payjoin_proposal( + /// Check the wallet for the session's settlement, then drive the session + /// if nothing settled it. + /// + /// Returns whether the session finished. + async fn resume_receiver_session( &self, - mut proposal: Receiver, + session: ReceiveSession, + address: &Address, persister: &ReceiverPersister, - ) -> Result<()> { - // 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); - - let mut interval = tokio::time::interval(poll_interval); - interval.tick().await; - - tracing::debug!("Polling for payment confirmation"); - - 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(()); - } - Ok(OptionalTransitionOutcome::Stasis(current_state)) => { - proposal = current_state; - } - 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"); - } - Err(e) => return Err(e.into()), - } + ) -> Result { + match self.check_settlement(&session, address, persister)? { + SettlementCheck::Closed => Ok(true), + SettlementCheck::Confirming => Ok(false), + SettlementCheck::Unsettled => self.process_receiver_session(session, persister).await, + } + } + + /// Classify the wallet's transactions paying `address` against the + /// session, and close it once a settling transaction has + /// `SETTLEMENT_CONFIRMATIONS`. + fn check_settlement( + &self, + session: &ReceiveSession, + address: &Address, + persister: &ReceiverPersister, + ) -> Result { + let Some(FoundSettlement { settlement, txid, confirmations }) = + self.find_settlement(session, address)? + else { + return Ok(SettlementCheck::Unsettled); + }; + + let settled_by = match settlement.outcome() { + SettlementOutcome::Payjoin => format!("Payjoin {txid}"), + SettlementOutcome::Fallback => format!("Fallback transaction {txid}"), + SettlementOutcome::Unrecognized => format!("Unrecognized transaction {txid}"), + other => format!("{other:?} transaction {txid}"), + }; + if confirmations < SETTLEMENT_CONFIRMATIONS { + persister.print(format_args!( + "{settled_by} spent the sender's inputs and has {confirmations} of {SETTLEMENT_CONFIRMATIONS} confirmation(s). Run `payjoin-cli resume` again once it confirms." + )); + return Ok(SettlementCheck::Confirming); + } + + persister.print(format_args!( + "{settled_by} settled the session with {confirmations} confirmation(s)." + )); + settlement.close().save(persister)?; + Ok(SettlementCheck::Closed) + } + + /// Find the most-confirmed wallet transaction paying `address` that + /// settles the session. + /// + /// The CLI never substitutes the receiver output, so both the fallback and + /// the Payjoin pay `address`. + fn find_settlement( + &self, + session: &ReceiveSession, + address: &Address, + ) -> Result> { + let mut deepest: Option = None; + for txid in self.wallet().txids_paying(address)? { + let Some((tx, confirmations)) = self.wallet().get_wallet_transaction(&txid)? else { + continue; + }; + let Some(settlement) = session.classify_tx(&tx) else { continue }; + if deepest.as_ref().is_none_or(|deepest| confirmations > deepest.confirmations) { + deepest = Some(FoundSettlement { settlement, txid, confirmations }); } - }) - .await; - - match result { - Ok(ok) => ok, - Err(_) => Err(anyhow!( - "No payjoin transaction detected in mempool within {timeout_duration:?}, stopping." - )), } + Ok(deepest) } /// Handle error by attempting to send an error response over the directory diff --git a/payjoin-cli/src/app/wallet.rs b/payjoin-cli/src/app/wallet.rs index e74f1dfce..042c2e870 100644 --- a/payjoin-cli/src/app/wallet.rs +++ b/payjoin-cli/src/app/wallet.rs @@ -1,4 +1,6 @@ use std::collections::HashMap; +#[cfg(feature = "v2")] +use std::str::FromStr; use std::sync::Arc; use anyhow::{anyhow, Context, Result}; @@ -12,6 +14,13 @@ use payjoin::bitcoin::{ }; use payjoin::receive::InputPair; +/// One address entry from listreceivedbyaddress. +#[cfg(feature = "v2")] +#[derive(Debug, serde::Deserialize)] +struct ReceivedByAddress { + txids: Vec, +} + #[derive(Clone)] pub struct BitcoindWallet { rpc: Arc, @@ -160,25 +169,45 @@ impl BitcoindWallet { } } + /// Returns a wallet transaction and its confirmation count, which is + /// negative if it conflicts with a confirmed transaction. #[cfg(feature = "v2")] - pub fn get_raw_transaction( - &self, - txid: &Txid, - ) -> Result> { - let raw_tx = tokio::task::block_in_place(|| { + pub fn get_wallet_transaction(&self, txid: &Txid) -> Result> { + let res = 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)), - Err(e) => - if e.is_tx_not_found() { - Ok(None) - } else { - Err(e) - }, + Ok(rpc_res) => Ok(Some((rpc_res.tx, rpc_res.confirmations))), + Err(e) if e.is_tx_not_found() => Ok(None), + Err(e) => Err(e), } }) })?; - Ok(raw_tx) + Ok(res) + } + + /// Returns the txids of all wallet transactions paying `address`, + /// including unconfirmed ones. + #[cfg(feature = "v2")] + pub fn txids_paying(&self, address: &Address) -> Result> { + // bitcoind-async-client has no wrapper for listreceivedbyaddress. + // Arguments: minconf, include_empty, include_watchonly, address_filter. + let params = [ + serde_json::json!(0), + serde_json::json!(false), + serde_json::json!(true), + serde_json::json!(address.to_string()), + ]; + let received: Vec = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current() + .block_on(async { self.rpc.call_raw("listreceivedbyaddress", ¶ms).await }) + }) + .context("Failed to list transactions received by address")?; + + received + .into_iter() + .flat_map(|entry| entry.txids) + .map(|txid| Txid::from_str(&txid).context("Invalid txid from listreceivedbyaddress")) + .collect() } /// Get a new address from the wallet diff --git a/payjoin-cli/src/cli/mod.rs b/payjoin-cli/src/cli/mod.rs index d371b71ad..74c17a352 100644 --- a/payjoin-cli/src/cli/mod.rs +++ b/payjoin-cli/src/cli/mod.rs @@ -131,7 +131,7 @@ pub enum Commands { #[arg(long = "expire-in")] expire_in: Option, }, - /// Resume pending payjoins (BIP77/v2 only) + /// Resume pending payjoins and close receiver sessions settled in the wallet (BIP77/v2 only) #[cfg(feature = "v2")] Resume { /// Only resume a specific session diff --git a/payjoin-cli/tests/e2e.rs b/payjoin-cli/tests/e2e.rs index 17559a82c..b8528ed68 100644 --- a/payjoin-cli/tests/e2e.rs +++ b/payjoin-cli/tests/e2e.rs @@ -751,6 +751,178 @@ mod e2e { Ok(()) } + /// A receiver session the sender settles with its fallback before the + /// receiver posts a proposal closes on `resume` once the fallback confirms. + #[cfg(feature = "v2")] + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn receiver_settles_fallback_before_proposal_v2( + ) -> Result<(), Box> { + use std::cell::Cell; + + use payjoin_test_utils::{init_tracing, TestServices}; + use tempfile::TempDir; + + type Result = std::result::Result; + + /// How long to wait for a payjoin-cli subprocess to print an expected line. + const STDOUT_TIMEOUT: tokio::time::Duration = tokio::time::Duration::from_secs(10); + + init_tracing(); + let mut services = TestServices::initialize_with_relays(3).await?; + let temp_dir = tempdir()?; + + let result = tokio::select! { + res = services.take_ohttp_relay_handle() => Err(format!("Ohttp relay is long running: {res:?}").into()), + res = services.take_directory_handle() => Err(format!("Directory server is long running: {res:?}").into()), + res = settle_fallback_cli_async(&services, &temp_dir) => res, + }; + + assert!( + result.is_ok(), + "receiver fallback settlement test failed: {:#?}", + result.unwrap_err() + ); + + /// Wait for `child` to print lines containing each of `expected`, in + /// order, then terminate it. + async fn wait_for_lines(mut child: Child, expected: &[&str]) -> Result<()> { + let mut stdout = child.stdout.take().expect("Failed to take child stdout"); + let next = Cell::new(0); + let res = tokio::time::timeout( + STDOUT_TIMEOUT, + wait_for_stdout_match(&mut stdout, |line| { + while next.get() < expected.len() && line.contains(expected[next.get()]) { + next.set(next.get() + 1); + } + next.get() == expected.len() + }), + ) + .await; + + terminate(child).await.expect("Failed to kill payjoin-cli"); + match res { + Ok(Some(_)) => Ok(()), + _ => Err(format!("payjoin-cli did not report {:?}", expected[next.get()]).into()), + } + } + + async fn settle_fallback_cli_async( + services: &TestServices, + temp_dir: &TempDir, + ) -> Result<()> { + let receiver_db_path = temp_dir.path().join("receiver_db"); + let sender_db_path = temp_dir.path().join("sender_db"); + let (bitcoind, sender, receiver) = init_bitcoind_sender_receiver(None, None)?; + + // Empty the receiver wallet so its session stops at input + // contribution, before a proposal exists. + let sweep_address = sender.new_address()?; + receiver.send_all(std::slice::from_ref(&sweep_address))?; + bitcoind.client.generate_to_address(1, &sweep_address)?; + + let cert_path = &temp_dir.path().join("localhost.der"); + tokio::fs::write(cert_path, services.cert()).await?; + services.wait_for_services_ready().await?; + let ohttp_keys = services.fetch_ohttp_keys().await?; + let ohttp_keys_path = temp_dir.path().join("ohttp_keys"); + tokio::fs::write(&ohttp_keys_path, ohttp_keys.encode()?).await?; + + let receiver_rpchost = format!("http://{}/wallet/receiver", bitcoind.params.rpc_socket); + let sender_rpchost = format!("http://{}/wallet/sender", bitcoind.params.rpc_socket); + let cookie_file = &bitcoind.params.cookie_file; + let payjoin_cli = env!("CARGO_BIN_EXE_payjoin-cli"); + let directory = &services.directory_url(); + let ohttp_relays = &services.ohttp_relay_urls(); + let cli = |rpchost: &str, db_path: &std::path::Path| { + let mut cmd = Command::new(payjoin_cli); + cmd.arg("--root-certificate") + .arg(cert_path) + .arg("--rpchost") + .arg(rpchost) + .arg("--cookie-file") + .arg(cookie_file) + .arg("--db-path") + .arg(db_path) + .arg("--ohttp-relays") + .arg(ohttp_relays) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); + cmd + }; + let resume_receiver = || { + cli(&receiver_rpchost, &receiver_db_path) + .arg("resume") + .spawn() + .expect("Failed to execute payjoin-cli") + }; + + // Receiver publishes a BIP21 and then goes offline. + let cli_receive_initiator = cli(&receiver_rpchost, &receiver_db_path) + .arg("receive") + .arg(RECEIVE_SATS) + .arg("--pj-directories") + .arg(directory) + .arg("--ohttp-keys") + .arg(&ohttp_keys_path) + .spawn() + .expect("Failed to execute payjoin-cli"); + let bip21 = get_bip21_from_receiver(cli_receive_initiator).await; + + // Sender posts the original proposal and waits for a response. + let mut cli_send_initiator = cli(&sender_rpchost, &sender_db_path) + .arg("send") + .arg(&bip21) + .arg("--fee-rate") + .arg("1") + .spawn() + .expect("Failed to execute payjoin-cli"); + let mut stdout = cli_send_initiator.stdout.take().expect("Failed to take child stdout"); + let posted_line = tokio::time::timeout( + tokio::time::Duration::from_secs(35), + wait_for_stdout_match(&mut stdout, |line| line.contains("No response yet.")), + ) + .await?; + terminate(cli_send_initiator).await.expect("Failed to kill payjoin-cli"); + // Session-scoped lines are prefixed with `[ ]`. + let sender_session_id = posted_line + .expect("sender should post the original proposal") + .split_whitespace() + .nth(1) + .expect("could not split stdout") + .trim_end_matches(']') + .to_string(); + + // Receiver retrieves the original and stops for lack of inputs. + wait_for_lines(resume_receiver(), &["No spendable UTXOs available in wallet"]).await?; + + // Sender gives up and broadcasts the fallback. + let cli_cancel = cli(&sender_rpchost, &sender_db_path) + .arg("cancel") + .arg(&sender_session_id) + .spawn() + .expect("Failed to execute payjoin-cli cancel"); + wait_for_lines(cli_cancel, &["Broadcasted fallback transaction txid"]).await?; + + // An unconfirmed fallback leaves the session open. + wait_for_lines( + resume_receiver(), + &["Fallback transaction", "has 0 of 1 confirmation(s)"], + ) + .await?; + + // Once the fallback confirms, resume closes the session. + bitcoind.client.generate_to_address(1, &sweep_address)?; + wait_for_lines( + resume_receiver(), + &["settled the session with 1 confirmation(s)", "Session completed."], + ) + .await?; + wait_for_lines(resume_receiver(), &["No sessions to resume."]).await?; + Ok(()) + } + Ok(()) + } + #[cfg(all(feature = "v1", feature = "v2", feature = "_manual-tls"))] #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn send_receive_payjoin_v2_to_v1() -> Result<(), Box>