Skip to content

Commit 0f711e8

Browse files
committed
Implement generic Receiver::cancel() method
This exposes a public API for canceling an active Payjoin session. This serves both manual triggers such as user action to "accelerate" a payment by immediately broadcasting the fallback, or automatic ones such as receiver wallet incapable of providing suitable inputs for a Payjoin. When applicable, it returns the fallback transaction both as a convenience and as a "hint" for the implementer to broadcast it now that the Payjoin is canceled.
1 parent 637f913 commit 0f711e8

3 files changed

Lines changed: 138 additions & 13 deletions

File tree

payjoin/src/core/persist.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,40 @@ impl<Event, NextState> NextStateTransition<Event, NextState> {
460460
}
461461
}
462462

463+
/// A transition that unconditionally terminates the session.
464+
///
465+
/// Unlike other transition types, this always succeeds at the protocol level
466+
/// (the only possible error is from the persister's storage layer).
467+
/// After saving, the session is closed and no further events can be appended.
468+
///
469+
/// The `T` parameter carries a value that is returned after saving without
470+
/// being persisted. This lets callers receive derived data (e.g. a fallback
471+
/// transaction) through the same `.save()` call pattern used by every other
472+
/// transition type.
473+
pub struct TerminalTransition<Event, T>(Event, T);
474+
475+
impl<Event, T> TerminalTransition<Event, T> {
476+
pub(crate) fn new(event: Event, value: T) -> Self { Self(event, value) }
477+
478+
pub fn save<P>(self, persister: &P) -> Result<T, P::InternalStorageError>
479+
where
480+
P: SessionPersister<SessionEvent = Event>,
481+
{
482+
PersistActions::SaveAndClose(self.0).execute(persister)?;
483+
Ok(self.1)
484+
}
485+
486+
pub async fn save_async<P>(self, persister: &P) -> Result<T, P::InternalStorageError>
487+
where
488+
P: AsyncSessionPersister<SessionEvent = Event>,
489+
Event: Send,
490+
T: Send,
491+
{
492+
PersistActions::SaveAndClose(self.0).execute_async(persister).await?;
493+
Ok(self.1)
494+
}
495+
}
496+
463497
/// A transition that can result in a succession completion, fatal error, or transient error.
464498
/// The transition can also result in no state change.
465499
pub enum MaybeFatalOrSuccessTransition<Event, CurrentState, Err> {

payjoin/src/core/receive/common/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ use crate::receive::{InternalPayloadError, OriginalPayload, PsbtContext};
3131
/// Call [`Self::commit_outputs`] to proceed.
3232
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
3333
pub struct WantsOutputs {
34-
original_psbt: Psbt,
34+
pub(super) original_psbt: Psbt,
3535
pub(super) payjoin_psbt: Psbt,
3636
pub(super) params: Params,
3737
change_vout: usize,

payjoin/src/core/receive/v2/mod.rs

Lines changed: 103 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ use crate::ohttp::{
5555
use crate::output_substitution::OutputSubstitution;
5656
use crate::persist::{
5757
MaybeFatalOrSuccessTransition, MaybeFatalTransition, MaybeFatalTransitionWithNoResults,
58-
MaybeSuccessTransition, MaybeTransientTransition, NextStateTransition,
58+
MaybeSuccessTransition, MaybeTransientTransition, NextStateTransition, TerminalTransition,
5959
};
6060
use crate::receive::{parse_payload, InputPair, OriginalPayload, PsbtContext};
6161
use crate::time::Time;
@@ -232,20 +232,73 @@ impl ReceiveSession {
232232
}
233233

234234
mod sealed {
235-
pub trait State {}
235+
pub trait State {
236+
fn fallback_tx(&self) -> Option<bitcoin::Transaction> { None }
237+
}
236238

237239
impl State for super::Initialized {}
238-
impl State for super::UncheckedOriginalPayload {}
239-
impl State for super::MaybeInputsOwned {}
240-
impl State for super::MaybeInputsSeen {}
241-
impl State for super::OutputsUnknown {}
242-
impl State for super::WantsOutputs {}
243-
impl State for super::WantsInputs {}
244-
impl State for super::WantsFeeRange {}
245-
impl State for super::ProvisionalProposal {}
246-
impl State for super::PayjoinProposal {}
240+
241+
impl State for super::UncheckedOriginalPayload {
242+
fn fallback_tx(&self) -> Option<bitcoin::Transaction> {
243+
Some(self.original.psbt.clone().extract_tx_unchecked_fee_rate())
244+
}
245+
}
246+
247+
impl State for super::MaybeInputsOwned {
248+
fn fallback_tx(&self) -> Option<bitcoin::Transaction> {
249+
Some(self.original.psbt.clone().extract_tx_unchecked_fee_rate())
250+
}
251+
}
252+
253+
impl State for super::MaybeInputsSeen {
254+
fn fallback_tx(&self) -> Option<bitcoin::Transaction> {
255+
Some(self.original.psbt.clone().extract_tx_unchecked_fee_rate())
256+
}
257+
}
258+
259+
impl State for super::OutputsUnknown {
260+
fn fallback_tx(&self) -> Option<bitcoin::Transaction> {
261+
Some(self.original.psbt.clone().extract_tx_unchecked_fee_rate())
262+
}
263+
}
264+
265+
impl State for super::WantsOutputs {
266+
fn fallback_tx(&self) -> Option<bitcoin::Transaction> {
267+
Some(self.inner.original_psbt.clone().extract_tx_unchecked_fee_rate())
268+
}
269+
}
270+
271+
impl State for super::WantsInputs {
272+
fn fallback_tx(&self) -> Option<bitcoin::Transaction> {
273+
Some(self.inner.original_psbt.clone().extract_tx_unchecked_fee_rate())
274+
}
275+
}
276+
277+
impl State for super::WantsFeeRange {
278+
fn fallback_tx(&self) -> Option<bitcoin::Transaction> {
279+
Some(self.inner.original_psbt.clone().extract_tx_unchecked_fee_rate())
280+
}
281+
}
282+
283+
impl State for super::ProvisionalProposal {
284+
fn fallback_tx(&self) -> Option<bitcoin::Transaction> {
285+
Some(self.psbt_context.original_psbt.clone().extract_tx_unchecked_fee_rate())
286+
}
287+
}
288+
289+
impl State for super::PayjoinProposal {
290+
fn fallback_tx(&self) -> Option<bitcoin::Transaction> {
291+
Some(self.psbt_context.original_psbt.clone().extract_tx_unchecked_fee_rate())
292+
}
293+
}
294+
247295
impl State for super::HasReplyableError {}
248-
impl State for super::Monitor {}
296+
297+
impl State for super::Monitor {
298+
fn fallback_tx(&self) -> Option<bitcoin::Transaction> {
299+
Some(self.psbt_context.original_psbt.clone().extract_tx_unchecked_fee_rate())
300+
}
301+
}
249302
}
250303

251304
/// Sealed trait for V2 receive session states.
@@ -255,6 +308,8 @@ mod sealed {
255308
/// can implement this trait, ensuring type safety and protocol integrity.
256309
pub trait State: sealed::State {}
257310

311+
impl<S: sealed::State> State for S {}
312+
258313
/// A higher-level receiver construct which will be taken through different states through the
259314
/// protocol workflow.
260315
///
@@ -285,6 +340,20 @@ impl<State> core::ops::DerefMut for Receiver<State> {
285340
fn deref_mut(&mut self) -> &mut Self::Target { &mut self.state }
286341
}
287342

343+
impl<S: State> Receiver<S> {
344+
/// Cancel the Payjoin session immediately.
345+
///
346+
/// Returns a [`TerminalTransition`] that, once persisted, yields the fallback
347+
/// transaction when applicable. The fallback transaction is the sender's original
348+
/// transaction that should be broadcast to complete the payment without Payjoin.
349+
///
350+
/// This is a terminal transition — the session cannot be used after cancellation.
351+
pub fn cancel(self) -> TerminalTransition<SessionEvent, Option<bitcoin::Transaction>> {
352+
let fallback = self.state.fallback_tx();
353+
TerminalTransition::new(SessionEvent::Closed(SessionOutcome::Cancel), fallback)
354+
}
355+
}
356+
288357
#[derive(Debug, Clone)]
289358
pub struct ReceiverBuilder(SessionContext);
290359

@@ -1830,4 +1899,26 @@ pub mod test {
18301899
let psbt = receiver.psbt_to_sign();
18311900
assert_eq!(psbt, PARSED_PAYJOIN_PROPOSAL.clone());
18321901
}
1902+
1903+
#[test]
1904+
fn cancel_from_initialized_returns_no_fallback() {
1905+
let persister = InMemoryTestPersister::<SessionEvent>::default();
1906+
let receiver = Receiver { state: Initialized {}, session_context: SHARED_CONTEXT.clone() };
1907+
let fallback = receiver.cancel().save(&persister).expect("save should succeed");
1908+
assert!(fallback.is_none());
1909+
assert!(persister.inner.read().expect("lock should not be poisoned").is_closed);
1910+
}
1911+
1912+
#[tokio::test]
1913+
async fn cancel_from_maybe_inputs_owned_returns_fallback() {
1914+
let persister = InMemoryTestPersister::<SessionEvent>::default();
1915+
let receiver = Receiver {
1916+
state: maybe_inputs_owned_v2_from_test_vector(),
1917+
session_context: SHARED_CONTEXT.clone(),
1918+
};
1919+
let expected_fallback = receiver.extract_tx_to_schedule_broadcast();
1920+
let fallback = receiver.cancel().save(&persister).expect("save should succeed");
1921+
assert_eq!(fallback, Some(expected_fallback));
1922+
assert!(persister.inner.read().expect("lock should not be poisoned").is_closed);
1923+
}
18331924
}

0 commit comments

Comments
 (0)