From c63b00c5b047def989f897a95ebe2ac72e1c1afb Mon Sep 17 00:00:00 2001 From: Armin Sabouri Date: Thu, 14 May 2026 14:10:54 -0400 Subject: [PATCH 1/2] Add sender `PendingFallback` typestate If application manually cancels the assumption is they will not attempt to resume that session again. The session is marked closed. The cancel type leaves the session open and the session can only be closed once the user confirms they broadcasted the fallback tx -- or took an equivalent action. --- payjoin-cli/src/app/v2/mod.rs | 55 +++++---- payjoin-ffi/csharp/UnitTests.cs | 30 +++-- .../dart/test/test_payjoin_unit_test.dart | 44 +++++-- payjoin-ffi/javascript/test/unit.test.ts | 44 ++++--- .../python/test/test_payjoin_unit_test.py | 18 ++- payjoin-ffi/src/send/mod.rs | 114 +++++++++++++----- payjoin/src/core/send/v2/mod.rs | 75 ++++++++++-- payjoin/src/core/send/v2/session.rs | 3 + 8 files changed, 279 insertions(+), 104 deletions(-) diff --git a/payjoin-cli/src/app/v2/mod.rs b/payjoin-cli/src/app/v2/mod.rs index 82fb87dcc..a34862ca6 100644 --- a/payjoin-cli/src/app/v2/mod.rs +++ b/payjoin-cli/src/app/v2/mod.rs @@ -13,8 +13,8 @@ use payjoin::receive::v2::{ WantsOutputs, }; use payjoin::send::v2::{ - replay_event_log as replay_sender_event_log, PollingForProposal, SendSession, Sender, - SenderBuilder, SessionOutcome as SenderSessionOutcome, WithReplyKey, + replay_event_log as replay_sender_event_log, PendingFallback, PollingForProposal, SendSession, + Sender, SenderBuilder, SessionOutcome as SenderSessionOutcome, WithReplyKey, }; use payjoin::{ImplementationError, PjParam, Uri}; use tokio::sync::watch; @@ -57,6 +57,7 @@ impl StatusText for SendSession { SenderSessionOutcome::Success(_) => "Session success", SenderSessionOutcome::Cancel => "Session cancelled", }, + SendSession::PendingFallback(_) => "Session awaiting fallback", } } } @@ -486,27 +487,33 @@ impl AppTrait for App { async fn fallback_sender(&self, session_id: SessionId) -> Result<()> { let persister = SenderPersister::from_id(self.db.clone(), session_id.clone()); - let (session, history) = replay_sender_event_log(&persister)?; - - if let SendSession::Closed(SenderSessionOutcome::Success(proposal)) = session { - let txid = proposal.clone().extract_tx_unchecked_fee_rate().compute_txid(); - println!( - "Session {session_id} already produced payjoin transaction {txid}. \ - Broadcasting the original now would double-spend against it. \ - If the payjoin tx needs re-broadcast, run \ - `bitcoin-cli gettransaction {txid}` to fetch the hex, then \ - `bitcoin-cli sendrawtransaction `." - ); - return Ok(()); - } + let (session, _history) = replay_sender_event_log(&persister)?; + + let pending: Sender = match session { + SendSession::PendingFallback(sender) => sender, + SendSession::WithReplyKey(sender) => sender.cancel().save(&persister)?, + SendSession::PollingForProposal(sender) => sender.cancel().save(&persister)?, + SendSession::Closed(SenderSessionOutcome::Success(proposal)) => { + let txid = proposal.extract_tx_unchecked_fee_rate().compute_txid(); + println!( + "Session {session_id} already produced payjoin transaction {txid}. \ + Broadcasting the original now would double-spend against it. \ + If the payjoin tx needs re-broadcast, run \ + `bitcoin-cli gettransaction {txid}` to fetch the hex, then \ + `bitcoin-cli sendrawtransaction `." + ); + return Ok(()); + } + SendSession::Closed(_) => { + println!("Session {session_id} is already closed. Nothing left to do."); + return Ok(()); + } + }; - let fallback_tx = history.fallback_tx(); - self.wallet().broadcast_tx(&fallback_tx)?; - println!("Broadcasted fallback transaction txid: {}", fallback_tx.compute_txid()); + self.wallet().broadcast_tx(pending.fallback_tx())?; + println!("Broadcasted fallback transaction txid: {}", pending.fallback_tx().compute_txid()); - if let Err(e) = SessionPersister::close(&persister) { - tracing::warn!("Failed to close session {session_id} after fallback: {e}"); - } + pending.close().save(&persister)?; Ok(()) } } @@ -539,9 +546,13 @@ impl App { } SendSession::Closed(SenderSessionOutcome::Failure) | SendSession::Closed(SenderSessionOutcome::Cancel) => { + println!("Session is closed. Nothing left to do"); + return Ok(()); + } + SendSession::PendingFallback(_) => { let id = persister.session_id(); println!( - "Session {id} ended without payjoin. Run `payjoin-cli fallback {id}` to broadcast the original transaction." + "Session {id} was cancelled. Run `payjoin-cli fallback {id}` to broadcast the original transaction." ); return Ok(()); } diff --git a/payjoin-ffi/csharp/UnitTests.cs b/payjoin-ffi/csharp/UnitTests.cs index f96b1318f..c4a456513 100644 --- a/payjoin-ffi/csharp/UnitTests.cs +++ b/payjoin-ffi/csharp/UnitTests.cs @@ -211,13 +211,16 @@ public void SenderCancelFromWithReplyKey() .BuildRecommended(1000) .Save(senderPersister); var cancelTransition = withReplyKey.Cancel(); - var fallbackTx = cancelTransition.Save(senderPersister); - Assert.NotNull(fallbackTx); - Assert.NotEmpty(fallbackTx); + var pendingFallback = cancelTransition.Save(senderPersister); + Assert.NotNull(pendingFallback); + Assert.NotEmpty(pendingFallback.FallbackTx()); - var result = PayjoinMethods.ReplaySenderEventLog(senderPersister); - var state = result.State(); - Assert.IsType(state); + var cancelledResult = PayjoinMethods.ReplaySenderEventLog(senderPersister); + Assert.IsType(cancelledResult.State()); + + pendingFallback.Close().Save(senderPersister); + var closedResult = PayjoinMethods.ReplaySenderEventLog(senderPersister); + Assert.IsType(closedResult.State()); } [Fact] @@ -238,13 +241,16 @@ public async Task SenderCancelFromWithReplyKeyAsync() .BuildRecommended(1000) .SaveAsync(senderPersister); var cancelTransition = withReplyKey.Cancel(); - var fallbackTx = await cancelTransition.SaveAsync(senderPersister); - Assert.NotNull(fallbackTx); - Assert.NotEmpty(fallbackTx); + var pendingFallback = await cancelTransition.SaveAsync(senderPersister); + Assert.NotNull(pendingFallback); + Assert.NotEmpty(pendingFallback.FallbackTx()); - var result = await PayjoinMethods.ReplaySenderEventLogAsync(senderPersister); - var state = result.State(); - Assert.IsType(state); + var cancelledResult = await PayjoinMethods.ReplaySenderEventLogAsync(senderPersister); + Assert.IsType(cancelledResult.State()); + + await pendingFallback.Close().SaveAsync(senderPersister); + var closedResult = await PayjoinMethods.ReplaySenderEventLogAsync(senderPersister); + Assert.IsType(closedResult.State()); } } diff --git a/payjoin-ffi/dart/test/test_payjoin_unit_test.dart b/payjoin-ffi/dart/test/test_payjoin_unit_test.dart index 3e9baa1d0..95c4cc0ca 100644 --- a/payjoin-ffi/dart/test/test_payjoin_unit_test.dart +++ b/payjoin-ffi/dart/test/test_payjoin_unit_test.dart @@ -183,14 +183,25 @@ void main() { .buildRecommended(minFeeRateSatPerKwu: 1000) .save(persister: sender_persister); var cancelTransition = withReplyKey.cancel(); - var fallbackTx = cancelTransition.save(persister: sender_persister); - expect(fallbackTx, isNotNull); - expect(fallbackTx.length, greaterThan(0)); - final result = payjoin.replaySenderEventLog(persister: sender_persister); + var pendingFallback = cancelTransition.save(persister: sender_persister); + expect(pendingFallback, isNotNull); + expect(pendingFallback!.fallbackTx().length, greaterThan(0)); + final cancelledResult = payjoin.replaySenderEventLog( + persister: sender_persister, + ); expect( - result.state(), + cancelledResult.state(), + isA(), + reason: "sender should be in PendingFallback state after cancel", + ); + pendingFallback.close().save(persister: sender_persister); + final closedResult = payjoin.replaySenderEventLog( + persister: sender_persister, + ); + expect( + closedResult.state(), isA(), - reason: "sender should be in Closed state after cancel", + reason: "sender should be in Closed state after close", ); }); @@ -215,18 +226,27 @@ void main() { .buildRecommended(minFeeRateSatPerKwu: 1000) .saveAsync(persister: sender_persister); var cancelTransition = withReplyKey.cancel(); - var fallbackTx = await cancelTransition.saveAsync( + var pendingFallback = await cancelTransition.saveAsync( persister: sender_persister, ); - expect(fallbackTx, isNotNull); - expect(fallbackTx.length, greaterThan(0)); - final result = await payjoin.replaySenderEventLogAsync( + expect(pendingFallback, isNotNull); + expect(pendingFallback!.fallbackTx().length, greaterThan(0)); + final cancelledResult = await payjoin.replaySenderEventLogAsync( persister: sender_persister, ); expect( - result.state(), + cancelledResult.state(), + isA(), + reason: "sender should be in PendingFallback state after cancel", + ); + await pendingFallback.close().saveAsync(persister: sender_persister); + final closedResult = await payjoin.replaySenderEventLogAsync( + persister: sender_persister, + ); + expect( + closedResult.state(), isA(), - reason: "sender should be in Closed state after cancel", + reason: "sender should be in Closed state after close", ); }); }); diff --git a/payjoin-ffi/javascript/test/unit.test.ts b/payjoin-ffi/javascript/test/unit.test.ts index 4b085c35f..972a2c2ab 100644 --- a/payjoin-ffi/javascript/test/unit.test.ts +++ b/payjoin-ffi/javascript/test/unit.test.ts @@ -210,19 +210,27 @@ function runUnitTests(name: string, payjoin: typeof nodejsPayjoin) { .save(senderPersister); const cancelTransition = withReplyKey.cancel(); - const fallbackTx = cancelTransition.save(senderPersister); - assert.ok(fallbackTx, "fallback tx should be returned"); + const pendingFallback = cancelTransition.save(senderPersister); + assert.ok(pendingFallback, "pending fallback should be returned"); assert.ok( - fallbackTx.byteLength > 0, + pendingFallback.fallbackTx().byteLength > 0, "fallback tx bytes should be non-empty", ); - const result = payjoin.replaySenderEventLog(senderPersister); - const state = result.state(); + const cancelledResult = + payjoin.replaySenderEventLog(senderPersister); assert.strictEqual( - state.tag, + cancelledResult.state().tag, + "PendingFallback", + "State should be PendingFallback after cancel", + ); + + pendingFallback.close().save(senderPersister); + const closedResult = payjoin.replaySenderEventLog(senderPersister); + assert.strictEqual( + closedResult.state().tag, "Closed", - "State should be Closed after cancel", + "State should be Closed after close", ); }); @@ -249,21 +257,29 @@ function runUnitTests(name: string, payjoin: typeof nodejsPayjoin) { .saveAsync(senderPersister); const cancelTransition = withReplyKey.cancel(); - const fallbackTx = + const pendingFallback = await cancelTransition.saveAsync(senderPersister); - assert.ok(fallbackTx, "fallback tx should be returned"); + assert.ok(pendingFallback, "pending fallback should be returned"); assert.ok( - fallbackTx.byteLength > 0, + pendingFallback.fallbackTx().byteLength > 0, "fallback tx bytes should be non-empty", ); - const result = + const cancelledResult = await payjoin.replaySenderEventLogAsync(senderPersister); - const state = result.state(); assert.strictEqual( - state.tag, + cancelledResult.state().tag, + "PendingFallback", + "State should be PendingFallback after cancel", + ); + + await pendingFallback.close().saveAsync(senderPersister); + const closedResult = + await payjoin.replaySenderEventLogAsync(senderPersister); + assert.strictEqual( + closedResult.state().tag, "Closed", - "State should be Closed after cancel", + "State should be Closed after close", ); }); }); diff --git a/payjoin-ffi/python/test/test_payjoin_unit_test.py b/payjoin-ffi/python/test/test_payjoin_unit_test.py index c63836de5..235f1f6f4 100644 --- a/payjoin-ffi/python/test/test_payjoin_unit_test.py +++ b/payjoin-ffi/python/test/test_payjoin_unit_test.py @@ -214,9 +214,12 @@ def test_sender_cancel(self): payjoin.SenderBuilder(psbt, uri).build_recommended(1000).save(persister) ) cancel_transition = with_reply_key.cancel() - fallback_tx = cancel_transition.save(persister) - self.assertIsNotNone(fallback_tx) - self.assertTrue(len(fallback_tx) > 0) + pending_fallback = cancel_transition.save(persister) + self.assertIsNotNone(pending_fallback) + self.assertTrue(len(pending_fallback.fallback_tx()) > 0) + result = payjoin.replay_sender_event_log(persister) + self.assertTrue(result.state().is_PENDING_FALLBACK()) + pending_fallback.close().save(persister) result = payjoin.replay_sender_event_log(persister) self.assertTrue(result.state().is_CLOSED()) @@ -251,9 +254,12 @@ async def run_test(): .save_async(persister) ) cancel_transition = with_reply_key.cancel() - fallback_tx = await cancel_transition.save_async(persister) - self.assertIsNotNone(fallback_tx) - self.assertTrue(len(fallback_tx) > 0) + pending_fallback = await cancel_transition.save_async(persister) + self.assertIsNotNone(pending_fallback) + self.assertTrue(len(pending_fallback.fallback_tx()) > 0) + result = await payjoin.replay_sender_event_log_async(persister) + self.assertTrue(result.state().is_PENDING_FALLBACK()) + await pending_fallback.close().save_async(persister) result = await payjoin.replay_sender_event_log_async(persister) self.assertTrue(result.state().is_CLOSED()) diff --git a/payjoin-ffi/src/send/mod.rs b/payjoin-ffi/src/send/mod.rs index d78863edc..2346ee40f 100644 --- a/payjoin-ffi/src/send/mod.rs +++ b/payjoin-ffi/src/send/mod.rs @@ -51,53 +51,50 @@ macro_rules! impl_save_for_transition { }; } -/// A terminal transition produced by cancelling a sender session. #[derive(uniffi::Object)] -pub struct SenderCancelTransition { - transition: RwLock< - Option< - payjoin::persist::TerminalTransition< - payjoin::send::v2::SessionEvent, - payjoin::bitcoin::Transaction, +#[allow(clippy::type_complexity)] +pub struct SenderCancelTransition( + Arc< + RwLock< + Option< + payjoin::persist::NextStateTransition< + payjoin::send::v2::SessionEvent, + payjoin::send::v2::Sender, + >, >, >, >, -} +); #[uniffi::export] impl SenderCancelTransition { - /// Persist the cancellation and return the fallback transaction. - /// - /// The fallback transaction is the consensus-encoded raw transaction bytes of - /// the sender's original transaction that should be broadcast to complete the - /// payment without Payjoin. pub fn save( &self, persister: Arc, - ) -> Result, SenderPersistedError> { + ) -> Result { let adapter = CallbackPersisterAdapter::new(persister); - let mut inner = self.transition.write().expect("Lock should not be poisoned"); + let mut inner = self.0.write().expect("Lock should not be poisoned"); let value = inner.take().expect("Already saved or moved"); - let fallback = value + let res = value .save(&adapter) .map_err(|e| SenderPersistedError::from(ImplementationError::new(e)))?; - Ok(payjoin::bitcoin::consensus::serialize(&fallback)) + Ok(res.into()) } pub async fn save_async( &self, persister: Arc, - ) -> Result, SenderPersistedError> { + ) -> Result { let adapter = AsyncCallbackPersisterAdapter::new(persister); let value = { - let mut inner = self.transition.write().expect("Lock should not be poisoned"); + let mut inner = self.0.write().expect("Lock should not be poisoned"); inner.take().expect("Already saved or moved") }; - let fallback = value + let res = value .save_async(&adapter) .await .map_err(|e| SenderPersistedError::from(ImplementationError::new(e)))?; - Ok(payjoin::bitcoin::consensus::serialize(&fallback)) + Ok(res.into()) } } @@ -107,14 +104,12 @@ macro_rules! impl_cancel_for_sender { impl $ty { /// Cancel the Payjoin session immediately. /// - /// Returns a [`SenderCancelTransition`] that, once persisted, yields the fallback - /// transaction. The fallback transaction is the sender's original transaction - /// that should be broadcast to complete the payment without Payjoin. - /// - /// This is a terminal transition — the session cannot be used after cancellation. + /// Returns a [`SenderCancelTransition`] that, once persisted, yields a + /// [`PendingFallback`] state. Call [`PendingFallback::fallback_tx`] to get + /// the original transaction pub fn cancel(&self) -> SenderCancelTransition { let transition = self.0.clone().cancel(); - SenderCancelTransition { transition: RwLock::new(Some(transition)) } + SenderCancelTransition(Arc::new(RwLock::new(Some(transition)))) } } }; @@ -184,6 +179,7 @@ impl SenderSessionOutcome { pub enum SendSession { WithReplyKey { inner: Arc }, PollingForProposal { inner: Arc }, + PendingFallback { inner: Arc }, Closed { inner: Arc }, } @@ -195,6 +191,8 @@ impl From for SendSession { Self::WithReplyKey { inner: Arc::new(inner.into()) }, SendSession::PollingForProposal(inner) => Self::PollingForProposal { inner: Arc::new(inner.into()) }, + SendSession::PendingFallback(inner) => + Self::PendingFallback { inner: Arc::new(inner.into()) }, SendSession::Closed(session_outcome) => Self::Closed { inner: Arc::new(session_outcome.into()) }, } @@ -645,6 +643,68 @@ impl PollingForProposal { } } +#[derive(Clone, uniffi::Object)] +pub struct PendingFallback(payjoin::send::v2::Sender); + +impl From> for PendingFallback { + fn from(value: payjoin::send::v2::Sender) -> Self { + Self(value) + } +} + +#[derive(uniffi::Object)] +#[allow(clippy::type_complexity)] +pub struct BroadcastedTransition( + Arc>>>, +); + +#[uniffi::export] +impl BroadcastedTransition { + pub fn save( + &self, + persister: Arc, + ) -> Result<(), SenderPersistedError> { + let adapter = CallbackPersisterAdapter::new(persister); + let mut inner = self.0.write().expect("Lock should not be poisoned"); + let value = inner.take().expect("Already saved or moved"); + value.save(&adapter).map_err(|e| SenderPersistedError::from(ImplementationError::new(e))) + } + + pub async fn save_async( + &self, + persister: Arc, + ) -> Result<(), SenderPersistedError> { + let adapter = AsyncCallbackPersisterAdapter::new(persister); + let value = { + let mut inner = self.0.write().expect("Lock should not be poisoned"); + inner.take().expect("Already saved or moved") + }; + value + .save_async(&adapter) + .await + .map_err(|e| SenderPersistedError::from(ImplementationError::new(e))) + } +} + +#[uniffi::export] +impl PendingFallback { + /// Returns the fallback transaction as consensus-encoded raw bytes. + /// + /// This is the sender's original transaction that should be broadcast to + /// complete the payment without Payjoin. + pub fn fallback_tx(&self) -> Vec { + payjoin::bitcoin::consensus::serialize(self.0.fallback_tx()) + } + + /// Mark the session as complete, signaling that the fallback transaction + /// has been broadcast or its control has been transferred. + /// + /// Persist the returned [`BroadcastedTransition`] to close the session. + pub fn close(&self) -> BroadcastedTransition { + BroadcastedTransition(Arc::new(RwLock::new(Some(self.0.close())))) + } +} + /// Session persister that should save and load events as JSON strings. #[uniffi::export(with_foreign)] pub trait JsonSenderSessionPersister: Send + Sync { diff --git a/payjoin/src/core/send/v2/mod.rs b/payjoin/src/core/send/v2/mod.rs index f0a345967..e18cccb14 100644 --- a/payjoin/src/core/send/v2/mod.rs +++ b/payjoin/src/core/send/v2/mod.rs @@ -246,17 +246,24 @@ impl Sender { } impl Sender { - /// Cancel the Payjoin session immediately. - /// - /// Returns a [`TerminalTransition`] that, once persisted, yields the fallback - /// transaction. The fallback transaction is the sender's original transaction that + /// Cancel the Payjoin session and once the transition is persisted, return a [`PendingFallback`] state. + /// The fallback transaction is the sender's original transaction that /// should be broadcast to complete the payment without Payjoin. - /// - /// This is a terminal transition — the session cannot be used after cancellation. - pub fn cancel(self) -> TerminalTransition { - let fallback = - self.session_context.psbt_ctx.original_psbt.clone().extract_tx_unchecked_fee_rate(); - TerminalTransition::new(SessionEvent::Closed(SessionOutcome::Cancel), fallback) + pub fn cancel(self) -> NextStateTransition> { + NextStateTransition::success( + SessionEvent::Cancelled(), + Sender { + state: PendingFallback { + fallback_tx: self + .session_context + .psbt_ctx + .original_psbt + .clone() + .extract_tx_unchecked_fee_rate(), + }, + session_context: self.session_context, + }, + ) } } @@ -268,6 +275,7 @@ impl Sender { pub enum SendSession { WithReplyKey(Sender), PollingForProposal(Sender), + PendingFallback(Sender), Closed(SessionOutcome), } @@ -287,6 +295,30 @@ impl SendSession { SendSession::PollingForProposal(_state), SessionEvent::Closed(SessionOutcome::Success(proposal)), ) => Ok(SendSession::Closed(SessionOutcome::Success(proposal))), + (SendSession::WithReplyKey(state), SessionEvent::Cancelled()) => + Ok(SendSession::PendingFallback(Sender { + state: PendingFallback { + fallback_tx: state + .session_context + .psbt_ctx + .original_psbt + .clone() + .extract_tx_unchecked_fee_rate(), + }, + session_context: state.session_context, + })), + (SendSession::PollingForProposal(state), SessionEvent::Cancelled()) => + Ok(SendSession::PendingFallback(Sender { + state: PendingFallback { + fallback_tx: state + .session_context + .psbt_ctx + .original_psbt + .clone() + .extract_tx_unchecked_fee_rate(), + }, + session_context: state.session_context, + })), (_, SessionEvent::Closed(session_outcome)) => Ok(SendSession::Closed(session_outcome)), (current_state, event) => Err(InternalReplayError::InvalidEvent( Box::new(event), @@ -555,6 +587,22 @@ impl Sender { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PendingFallback { + fallback_tx: bitcoin::Transaction, +} + +impl Sender { + /// Returns the fallback transaction that should be broadcast to complete the payment without Payjoin. + pub fn fallback_tx(&self) -> &bitcoin::Transaction { &self.fallback_tx } + + /// Mark the session as complete, signaling that the fallback transaction + /// has been broadcast or its control has been transferred. + pub fn close(&self) -> TerminalTransition { + TerminalTransition::new(SessionEvent::Closed(SessionOutcome::Cancel), ()) + } +} + #[cfg(test)] mod test { use std::str::FromStr; @@ -732,7 +780,12 @@ mod test { .cancel() .save(&persister) .expect("save should succeed"); - assert_eq!(fallback, expected_tx, "cancel from {}", stringify!($state)); + assert_eq!( + *fallback.fallback_tx(), + expected_tx, + "cancel from {}", + stringify!($state) + ); }}; } diff --git a/payjoin/src/core/send/v2/session.rs b/payjoin/src/core/send/v2/session.rs index 1f0f8de18..fd4f27e30 100644 --- a/payjoin/src/core/send/v2/session.rs +++ b/payjoin/src/core/send/v2/session.rs @@ -153,6 +153,8 @@ pub enum SessionEvent { Created(Box), /// Sender POSTed the Original PSBT and is waiting to receive a Proposal PSBT PostedOriginalPsbt(), + /// User initiated cancellation of the session + Cancelled(), /// Closed successful or failed session Closed(SessionOutcome), } @@ -222,6 +224,7 @@ mod tests { SessionEvent::Closed(SessionOutcome::Success(PARSED_ORIGINAL_PSBT.clone())), SessionEvent::Closed(SessionOutcome::Failure), SessionEvent::Closed(SessionOutcome::Cancel), + SessionEvent::Cancelled(), ]; for event in test_cases { From 3bb4625672e2b21a90d5cff8121fa23c250f168d Mon Sep 17 00:00:00 2001 From: Armin Sabouri Date: Thu, 14 May 2026 14:19:24 -0400 Subject: [PATCH 2/2] Merge fallback into cancel command Unifying them removes the two-command workflow: `cancel` now cancels the session and broadcasts the fallback transaction by default. Pass `--no-broadcast` to print the raw transaction hex instead for manual broadcast. --- payjoin-cli/src/app/config.rs | 2 +- payjoin-cli/src/app/mod.rs | 2 +- payjoin-cli/src/app/v1.rs | 8 ++++++-- payjoin-cli/src/app/v2/mod.rs | 30 ++++++++++++++++++------------ payjoin-cli/src/cli/mod.rs | 10 +++++++--- payjoin-cli/src/main.rs | 4 ++-- payjoin-cli/tests/e2e.rs | 35 +++++++++++++++-------------------- 7 files changed, 50 insertions(+), 41 deletions(-) diff --git a/payjoin-cli/src/app/config.rs b/payjoin-cli/src/app/config.rs index f0cd22a0b..56f3db1b0 100644 --- a/payjoin-cli/src/app/config.rs +++ b/payjoin-cli/src/app/config.rs @@ -337,7 +337,7 @@ fn handle_subcommands(config: Builder, cli: &Cli) -> Result Ok(config), #[cfg(feature = "v2")] - Commands::Fallback { .. } => Ok(config), + Commands::Cancel { .. } => Ok(config), } } diff --git a/payjoin-cli/src/app/mod.rs b/payjoin-cli/src/app/mod.rs index 48499bebe..71c21039c 100644 --- a/payjoin-cli/src/app/mod.rs +++ b/payjoin-cli/src/app/mod.rs @@ -31,7 +31,7 @@ pub trait App: Send + Sync { #[cfg(feature = "v2")] async fn history(&self) -> Result<()>; #[cfg(feature = "v2")] - async fn fallback_sender(&self, session_id: SessionId) -> Result<()>; + async fn cancel_sender(&self, session_id: SessionId, no_broadcast: bool) -> Result<()>; fn create_original_psbt( &self, diff --git a/payjoin-cli/src/app/v1.rs b/payjoin-cli/src/app/v1.rs index 5d98ddc26..f557facd3 100644 --- a/payjoin-cli/src/app/v1.rs +++ b/payjoin-cli/src/app/v1.rs @@ -129,8 +129,12 @@ impl AppTrait for App { } #[cfg(feature = "v2")] - async fn fallback_sender(&self, _session_id: crate::db::v2::SessionId) -> Result<()> { - anyhow::bail!("fallback is only supported for v2 (BIP77) sessions") + async fn cancel_sender( + &self, + _session_id: crate::db::v2::SessionId, + _no_broadcast: bool, + ) -> Result<()> { + anyhow::bail!("cancel is only supported for v2 (BIP77) sessions") } } diff --git a/payjoin-cli/src/app/v2/mod.rs b/payjoin-cli/src/app/v2/mod.rs index a34862ca6..67dca2910 100644 --- a/payjoin-cli/src/app/v2/mod.rs +++ b/payjoin-cli/src/app/v2/mod.rs @@ -257,7 +257,7 @@ impl AppTrait for App { Ok(()) => return Ok(()), Err(err) => { let id = persister.session_id(); - println!("Session {id} failed. Run `payjoin-cli fallback {id}` to broadcast the original transaction."); + println!("Session {id} failed. Run `payjoin-cli cancel {id}` to cancel and broadcast the original transaction."); return Err(err); } } @@ -265,7 +265,7 @@ impl AppTrait for App { _ = interrupt.changed() => { let id = persister.session_id(); println!( - "Session {id} interrupted. Call `send` again to resume, `resume` to resume all sessions, or `payjoin-cli fallback {id}` to broadcast the original transaction." + "Session {id} interrupted. Call `send` again to resume, `resume` to resume all sessions, or `payjoin-cli cancel {id}` to cancel and broadcast the original transaction." ); return Err(anyhow!("Interrupted")) } @@ -485,22 +485,19 @@ impl AppTrait for App { Ok(()) } - async fn fallback_sender(&self, session_id: SessionId) -> Result<()> { + async fn cancel_sender(&self, session_id: SessionId, no_broadcast: bool) -> Result<()> { let persister = SenderPersister::from_id(self.db.clone(), session_id.clone()); let (session, _history) = replay_sender_event_log(&persister)?; let pending: Sender = match session { - SendSession::PendingFallback(sender) => sender, SendSession::WithReplyKey(sender) => sender.cancel().save(&persister)?, SendSession::PollingForProposal(sender) => sender.cancel().save(&persister)?, + SendSession::PendingFallback(sender) => sender, SendSession::Closed(SenderSessionOutcome::Success(proposal)) => { let txid = proposal.extract_tx_unchecked_fee_rate().compute_txid(); println!( "Session {session_id} already produced payjoin transaction {txid}. \ - Broadcasting the original now would double-spend against it. \ - If the payjoin tx needs re-broadcast, run \ - `bitcoin-cli gettransaction {txid}` to fetch the hex, then \ - `bitcoin-cli sendrawtransaction `." + Cannot cancel a completed session." ); return Ok(()); } @@ -510,9 +507,18 @@ impl AppTrait for App { } }; - self.wallet().broadcast_tx(pending.fallback_tx())?; - println!("Broadcasted fallback transaction txid: {}", pending.fallback_tx().compute_txid()); - + if no_broadcast { + println!( + "Session {session_id} cancelled. Broadcast the original transaction manually:\n{}", + serialize_hex(pending.fallback_tx()) + ); + } else { + self.wallet().broadcast_tx(pending.fallback_tx())?; + println!( + "Broadcasted fallback transaction txid: {}", + pending.fallback_tx().compute_txid() + ); + } pending.close().save(&persister)?; Ok(()) } @@ -552,7 +558,7 @@ impl App { SendSession::PendingFallback(_) => { let id = persister.session_id(); println!( - "Session {id} was cancelled. Run `payjoin-cli fallback {id}` to broadcast the original transaction." + "Session {id} was cancelled. Run `payjoin-cli cancel {id}` to cancel and broadcast the original transaction." ); return Ok(()); } diff --git a/payjoin-cli/src/cli/mod.rs b/payjoin-cli/src/cli/mod.rs index 7cef2551b..07b087edb 100644 --- a/payjoin-cli/src/cli/mod.rs +++ b/payjoin-cli/src/cli/mod.rs @@ -133,11 +133,15 @@ pub enum Commands { /// Show payjoin session history History, #[cfg(feature = "v2")] - /// Broadcast the original transaction for a sender session (BIP77/v2 only) - Fallback { - /// The session ID to broadcast the fallback transaction for + /// Cancel a sender session, broadcasting the fallback transaction by default (BIP77/v2 only) + Cancel { + /// The session ID to cancel #[arg(required = true)] session_id: i64, + + /// Cancel without broadcasting the fallback transaction + #[arg(long = "no-broadcast")] + no_broadcast: bool, }, } diff --git a/payjoin-cli/src/main.rs b/payjoin-cli/src/main.rs index 6b2b038f4..b988a46ea 100644 --- a/payjoin-cli/src/main.rs +++ b/payjoin-cli/src/main.rs @@ -79,8 +79,8 @@ async fn main() -> Result<()> { app.history().await?; } #[cfg(feature = "v2")] - Commands::Fallback { session_id } => { - app.fallback_sender(SessionId(*session_id)).await?; + Commands::Cancel { session_id, no_broadcast } => { + app.cancel_sender(SessionId(*session_id), *no_broadcast).await?; } }; diff --git a/payjoin-cli/tests/e2e.rs b/payjoin-cli/tests/e2e.rs index fe761e1fe..827c5543b 100644 --- a/payjoin-cli/tests/e2e.rs +++ b/payjoin-cli/tests/e2e.rs @@ -624,7 +624,7 @@ mod e2e { #[cfg(feature = "v2")] #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn sender_fallback_v2() -> Result<(), Box> { + async fn sender_cancel_v2() -> Result<(), Box> { use payjoin_test_utils::{init_tracing, TestServices}; use tempfile::TempDir; @@ -637,12 +637,12 @@ mod e2e { 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 = fallback_cli_async(&services, &temp_dir) => res, + res = cancel_cli_async(&services, &temp_dir) => res, }; - assert!(result.is_ok(), "sender_fallback_v2 failed: {:#?}", result.unwrap_err()); + assert!(result.is_ok(), "sender_cancel_v2 failed: {:#?}", result.unwrap_err()); - async fn fallback_cli_async(services: &TestServices, temp_dir: &TempDir) -> Result<()> { + async fn cancel_cli_async(services: &TestServices, temp_dir: &TempDir) -> Result<()> { let sender_db_path = temp_dir.path().join("sender_db"); let (bitcoind, sender, _receiver) = init_bitcoind_sender_receiver(None, None)?; let cert_path = &temp_dir.path().join("localhost.der"); @@ -684,7 +684,7 @@ mod e2e { .expect("Failed to execute payjoin-cli receiver"); let bip21 = get_bip21_from_receiver(cli_receiver).await; - // Start sender and capture the session-id from the hint line, then interrupt + // Start sender and let it time out waiting for a response let cli_sender = Command::new(payjoin_cli) .arg("--root-certificate") .arg(cert_path) @@ -709,13 +709,9 @@ mod e2e { // There is only one sender session in progress. let session_id = 1i64; - // Ensure the fallback was not broadcast yet - let mempool_size = - sender.get_mempool_info().expect("should be able to get mempool").unbroadcast_count; - assert_eq!(mempool_size, 0, "fallback should not be in mempool"); - // Run `payjoin-cli fallback ` and assert broadcast - let mut cli_fallback = Command::new(payjoin_cli) + // Run `payjoin-cli cancel `: cancels and broadcasts the fallback tx + let mut cli_cancel = Command::new(payjoin_cli) .arg("--root-certificate") .arg(cert_path) .arg("--rpchost") @@ -726,32 +722,31 @@ mod e2e { .arg(&sender_db_path) .arg("--ohttp-relays") .arg(ohttp_relay) - .arg("fallback") + .arg("cancel") .arg(session_id.to_string()) .stdout(Stdio::piped()) .stderr(Stdio::inherit()) .spawn() - .expect("Failed to execute payjoin-cli fallback"); + .expect("Failed to execute payjoin-cli cancel"); - let mut fallback_stdout = - cli_fallback.stdout.take().expect("failed to take stdout of fallback"); + let mut cancel_stdout = + cli_cancel.stdout.take().expect("failed to take stdout of cancel"); let timeout = tokio::time::Duration::from_secs(10); let broadcast_line = tokio::time::timeout( timeout, - wait_for_stdout_match(&mut fallback_stdout, |l| { + wait_for_stdout_match(&mut cancel_stdout, |l| { l.contains("Broadcasted fallback transaction txid") }), ) .await?; - - terminate(cli_fallback).await.expect("Failed to kill payjoin-cli fallback"); - let subcommand_output = broadcast_line.expect("fallback should broadcast"); + terminate(cli_cancel).await.expect("Failed to kill payjoin-cli cancel"); + let subcommand_output = broadcast_line.expect("cancel should broadcast fallback tx"); let fallback_txid = subcommand_output.split_whitespace().nth(4).unwrap_or(""); let fallback_txid = Txid::from_str(fallback_txid).expect("valid txid"); assert!( sender.get_raw_transaction(fallback_txid).is_ok(), - "fallback tx should be in the mempool" + "fallback tx should be in the mempool after cancel" ); Ok(())