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 82fb87dcc..67dca2910 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", } } } @@ -256,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); } } @@ -264,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")) } @@ -484,29 +485,41 @@ 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 (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(); + let pending: Sender = match session { + 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}. \ + Cannot cancel a completed session." + ); + return Ok(()); + } + SendSession::Closed(_) => { + println!("Session {session_id} is already closed. Nothing left to do."); + return Ok(()); + } + }; + + if no_broadcast { 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 `." + "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() ); - return Ok(()); - } - - let fallback_tx = history.fallback_tx(); - self.wallet().broadcast_tx(&fallback_tx)?; - println!("Broadcasted fallback transaction txid: {}", 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 +552,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 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(()) 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 {