From b136e4ab251cebfb59b93eb9df960458453fbce3 Mon Sep 17 00:00:00 2001 From: spacebear Date: Mon, 19 Oct 2026 15:47:22 +0200 Subject: [PATCH 1/3] 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 | 17 +++++++++++++++-- 2 files changed, 21 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..8370f5ca1 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,22 @@ 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. It does not expose the +/// fallback transaction itself: cancel the session with [`Receiver::cancel`] and read it +/// from [`Receiver::fallback_tx`]. +/// +/// ```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 917893e118546c1c8eb2a2c8f94a70e9db971445 Mon Sep 17 00:00:00 2001 From: spacebear Date: Mon, 19 Oct 2026 15:50:41 +0200 Subject: [PATCH 2/3] Point payjoin-cli at cancel for the fallback tx When the Original PSBT arrived, the v2 receiver printed its hex and suggested broadcasting it by hand if the payjoin failed. That is the pattern payjoin is moving away from: a fallback broadcast should go through the cancel path, which records the Cancelled event in the session log and hands back the transaction as Receiver, so the session history and the chain agree on what happened. The CLI already implements that path as `payjoin-cli cancel `. Replace the hex dump with a pointer to the cancel command, matching the wording used when a session is interrupted or fails. This also removes the CLI's only call to extract_tx_to_schedule_broadcast ahead of its deprecation. --- payjoin-cli/CHANGELOG.md | 4 ++++ payjoin-cli/src/app/v2/mod.rs | 8 ++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/payjoin-cli/CHANGELOG.md b/payjoin-cli/CHANGELOG.md index be97ca13f..6c41ca80d 100644 --- a/payjoin-cli/CHANGELOG.md +++ b/payjoin-cli/CHANGELOG.md @@ -5,6 +5,10 @@ - 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. +- The v2 receiver no longer prints the fallback transaction hex when the + Original PSBT arrives. The message now points to + `payjoin-cli cancel `, which broadcasts the fallback transaction + if the payjoin fails. ## 1.0.0 diff --git a/payjoin-cli/src/app/v2/mod.rs b/payjoin-cli/src/app/v2/mod.rs index 924129b13..f1c174893 100644 --- a/payjoin-cli/src/app/v2/mod.rs +++ b/payjoin-cli/src/app/v2/mod.rs @@ -1028,10 +1028,10 @@ impl App { let proposal = proposal.apply_broadcast_suitability(None, is_broadcast_suitable).save(persister)?; - persister.print( - "Fallback transaction received. Consider broadcasting this to get paid if the Payjoin fails:", - ); - println!("{}", serialize_hex(&proposal.extract_tx_to_schedule_broadcast())); + let id = persister.session_id(); + persister.print(format_args!( + "Fallback transaction received. Run `payjoin-cli cancel {id}` to cancel and broadcast it if the Payjoin fails." + )); Ok(ReceiveSession::MaybeInputsOwned(proposal)) } From 63d58330bb7683cff85551e565d4019671fe681d Mon Sep 17 00:00:00 2001 From: spacebear Date: Mon, 19 Oct 2026 16:08:27 +0200 Subject: [PATCH 3/3] Deprecate extract_tx_to_schedule_broadcast Receiver::extract_tx_to_schedule_broadcast handed out the fallback transaction up front so an application could schedule its broadcast out of band. A broadcast scheduled that way leaves no trace in the session event log: the session stays Active while the fallback is already on the chain, and the log only learns of it if the session later reaches Monitor and check_for_transaction finds the fallback. Scheduling a fallback broadcast should instead go through cancel(). It is available from every state that holds a verified fallback, records the Cancelled event, and yields Receiver, whose fallback_tx is the transaction to broadcast before close() ends the session. Applications that need the transaction after the fact can read it from SessionHistory::fallback_tx. Mark the v2 method deprecated and point its docs at cancel(). The v1 receiver keeps its method: it has no session or cancel path. Move the session tests that used it as a shortcut to the original PSBT, drop the unused call from the integration test, and keep the FFI wrapper compiling with an allow while its docstring carries the deprecation, since UniFFI has no deprecation attribute. --- payjoin-ffi/CHANGELOG.md | 4 ++++ payjoin-ffi/src/receive/mod.rs | 5 +++++ payjoin/CHANGELOG.md | 4 ++++ payjoin/src/core/receive/v2/mod.rs | 18 +++++++++++++----- payjoin/src/core/receive/v2/session.rs | 11 +++-------- payjoin/tests/integration.rs | 3 --- 6 files changed, 29 insertions(+), 16 deletions(-) diff --git a/payjoin-ffi/CHANGELOG.md b/payjoin-ffi/CHANGELOG.md index 31f103cda..ac0a7d407 100644 --- a/payjoin-ffi/CHANGELOG.md +++ b/payjoin-ffi/CHANGELOG.md @@ -1,5 +1,9 @@ ## Unreleased +- Deprecate `MaybeInputsOwned.extract_tx_to_schedule_broadcast`. Cancel the + session when the payjoin fails and broadcast the `fallback_tx` of the + resulting `ReceiverPendingFallback` instead. + ## [0.25.0] - Publish the Python, JavaScript, C#, and Dart packages from one signed diff --git a/payjoin-ffi/src/receive/mod.rs b/payjoin-ffi/src/receive/mod.rs index b13dacb0d..fd9939614 100644 --- a/payjoin-ffi/src/receive/mod.rs +++ b/payjoin-ffi/src/receive/mod.rs @@ -942,10 +942,15 @@ pub trait IsInputOwned: Send + Sync { #[uniffi::export] impl MaybeInputsOwned { + /// Deprecated since payjoin 0.0.0-unreleased: cancel the session when the payjoin + /// fails and broadcast the `fallback_tx` of the resulting `ReceiverPendingFallback` + /// instead. + /// /// Extract the transaction from the Original PSBT for scheduling broadcast as a /// fallback in case the payjoin does not complete. /// /// Returns the consensus-encoded raw transaction bytes. + #[allow(deprecated)] pub fn extract_tx_to_schedule_broadcast(&self) -> Vec { payjoin::bitcoin::consensus::encode::serialize( &self.0.clone().extract_tx_to_schedule_broadcast(), diff --git a/payjoin/CHANGELOG.md b/payjoin/CHANGELOG.md index 5ec2e6625..a11dc6dd2 100644 --- a/payjoin/CHANGELOG.md +++ b/payjoin/CHANGELOG.md @@ -7,6 +7,10 @@ documented API; such code no longer compiles. Read the fallback transaction from `Receiver::fallback_tx` after `cancel`, or from `SessionHistory::fallback_tx`. +- Deprecate `Receiver::extract_tx_to_schedule_broadcast`. + Schedule the fallback broadcast by cancelling the session when the payjoin + fails: `cancel` yields a `Receiver` whose `fallback_tx` + is the transaction to broadcast. ## 1.2.0 diff --git a/payjoin/src/core/receive/v2/mod.rs b/payjoin/src/core/receive/v2/mod.rs index 8370f5ca1..66c1fee0d 100644 --- a/payjoin/src/core/receive/v2/mod.rs +++ b/payjoin/src/core/receive/v2/mod.rs @@ -917,9 +917,9 @@ pub struct MaybeInputsOwned { /// Typestate to check that the Original PSBT has no inputs owned by the receiver. /// -/// At this point, the Original PSBT has been verified as broadcastable; the receiver -/// can call [`Receiver::extract_tx_to_schedule_broadcast`] to -/// schedule a fallback broadcast in case the payjoin fails. +/// At this point, the Original PSBT has been verified as broadcastable. If the payjoin +/// fails from here on, call [`Receiver::cancel`] to obtain the +/// fallback transaction to broadcast. /// /// Call [`Receiver::check_inputs_not_owned`] to advance to /// [`Receiver`] to continue validation. @@ -928,10 +928,18 @@ pub struct MaybeInputsOwned { /// [`Receiver::inputs_owned_checklist`] and /// [`Receiver::apply_inputs_owned_checklist`]. impl Receiver { - /// Extract the transaction from the Original PSBT for scheduling broadcast as a - /// fallback in case the payjoin does not complete. + /// Extract the transaction from the Original PSBT. + /// + /// Broadcasting it from here bypasses the session: the event log never learns that + /// the fallback was sent. When the payjoin fails, call + /// [`Receiver::cancel`] instead and broadcast + /// [`Receiver::fallback_tx`]. /// /// Returns the extracted [`bitcoin::Transaction`]. + #[deprecated( + since = "0.0.0-unreleased", + note = "cancel the session when the payjoin fails and broadcast `Receiver::fallback_tx` instead" + )] pub fn extract_tx_to_schedule_broadcast(&self) -> bitcoin::Transaction { self.state.fallback_tx() } diff --git a/payjoin/src/core/receive/v2/session.rs b/payjoin/src/core/receive/v2/session.rs index 63fd3c3da..97887dafb 100644 --- a/payjoin/src/core/receive/v2/session.rs +++ b/payjoin/src/core/receive/v2/session.rs @@ -840,15 +840,10 @@ mod tests { #[tokio::test] async fn getting_fallback_tx() { - let persister = InMemoryPersister::::default(); let session_context = SHARED_CONTEXT.clone(); let mut events = vec![]; let original = original_from_test_vector(); - let maybe_inputs_owned = unchecked_receiver_from_test_vector() - .assume_interactive_receiver() - .save(&persister) - .unwrap(); - let expected_fallback = maybe_inputs_owned.extract_tx_to_schedule_broadcast(); + let expected_fallback = original.psbt.clone().extract_tx_unchecked_fee_rate(); let reply_key = Some(crate::HpkeKeyPair::gen_keypair().1); events.push(SessionEvent::Created(session_context.clone())); @@ -1006,7 +1001,7 @@ mod tests { .apply_fee_range(None, None) .save(&persister) .expect("Contributed inputs should be valid"); - let expected_fallback = maybe_inputs_owned.extract_tx_to_schedule_broadcast(); + let expected_fallback = original.psbt.clone().extract_tx_unchecked_fee_rate(); let reply_key = Some(crate::HpkeKeyPair::gen_keypair().1); events.push(SessionEvent::Created(session_context.clone())); @@ -1087,7 +1082,7 @@ mod tests { .finalize_proposal(|psbt| Ok(psbt.clone())) .save(&persister) .expect("Payjoin proposal should be finalized"); - let expected_fallback = maybe_inputs_owned.extract_tx_to_schedule_broadcast(); + let expected_fallback = original.psbt.clone().extract_tx_unchecked_fee_rate(); let reply_key = Some(crate::HpkeKeyPair::gen_keypair().1); events.push(SessionEvent::Created(session_context.clone())); diff --git a/payjoin/tests/integration.rs b/payjoin/tests/integration.rs index 2b67a4ce9..c62846142 100644 --- a/payjoin/tests/integration.rs +++ b/payjoin/tests/integration.rs @@ -1165,9 +1165,6 @@ mod integration { }) .save(recv_persister)?; - // in a payment processor where the sender could go offline, this is where you schedule to broadcast the original_tx - let _to_broadcast_in_failure_case = proposal.extract_tx_to_schedule_broadcast(); - // Receive Check 2: receiver can't sign for proposal inputs let proposal = proposal .check_inputs_not_owned(&mut |outpoint| {