From d2d4205f0c9ec07ff66b3bfe1cec049bd6950084 Mon Sep 17 00:00:00 2001 From: Benalleng Date: Tue, 23 Jun 2026 16:04:22 -0400 Subject: [PATCH 1/3] Print fallback tx when re-cancelling closed session Print the fallback tx hex from SessionHistory::fallback_tx() in the Closed(Aborted) arm of cancel_sender_session and cancel_receiver_session. --- payjoin-cli/src/app/v2/mod.rs | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/payjoin-cli/src/app/v2/mod.rs b/payjoin-cli/src/app/v2/mod.rs index 8e7af2287..cd4d3d9e3 100644 --- a/payjoin-cli/src/app/v2/mod.rs +++ b/payjoin-cli/src/app/v2/mod.rs @@ -546,7 +546,7 @@ impl AppTrait for App { impl App { fn cancel_sender_session(&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)?; let pending: Sender = match session { SendSession::WithReplyKey(sender) => sender.cancel().save(&persister)?, @@ -560,8 +560,11 @@ impl App { ); return Ok(()); } - SendSession::Closed(_) => { - println!("Session {session_id} is already closed. Nothing left to do."); + SendSession::Closed(SenderSessionOutcome::Aborted) => { + println!( + "Session {session_id} was already cancelled. Broadcast the original transaction manually:\n{}", + serialize_hex(&history.fallback_tx()) + ); return Ok(()); } }; @@ -584,7 +587,7 @@ impl App { fn cancel_receiver_session(&self, session_id: SessionId, no_broadcast: bool) -> Result<()> { let persister = ReceiverPersister::from_id(self.db.clone(), session_id.clone()); - let (session, _history) = replay_receiver_event_log(&persister)?; + let (session, history) = replay_receiver_event_log(&persister)?; let pending: Receiver = match session { ReceiveSession::Initialized(receiver) => { @@ -625,8 +628,16 @@ impl App { println!("Session {session_id} already completed successfully. Cannot cancel."); return Ok(()); } - ReceiveSession::Closed(_) => { - println!("Session {session_id} is already closed. Nothing left to do."); + ReceiveSession::Closed(ReceiverSessionOutcome::Aborted) => { + match history.fallback_tx() { + Some(tx) => println!( + "Session {session_id} was already cancelled. Broadcast the fallback transaction manually:\n{}", + serialize_hex(&tx) + ), + None => println!( + "Session {session_id} is already closed. No fallback transaction available." + ), + } return Ok(()); } }; From 127fc782417416630dd05bd4e719b186ccaabce9 Mon Sep 17 00:00:00 2001 From: Benalleng Date: Tue, 23 Jun 2026 19:30:14 -0400 Subject: [PATCH 2/3] Necessitate only session id for cancel on inactive sessions --- payjoin-cli/src/app/v2/mod.rs | 6 ++---- payjoin-cli/src/db/v2.rs | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/payjoin-cli/src/app/v2/mod.rs b/payjoin-cli/src/app/v2/mod.rs index cd4d3d9e3..cd0cf38ee 100644 --- a/payjoin-cli/src/app/v2/mod.rs +++ b/payjoin-cli/src/app/v2/mod.rs @@ -526,10 +526,8 @@ impl AppTrait for App { }; } - let send_ids = self.db.get_send_session_ids()?; - let recv_ids = self.db.get_recv_session_ids()?; - let is_sender = send_ids.iter().any(|id| id.0 == session_id.0); - let is_receiver = recv_ids.iter().any(|id| id.0 == session_id.0); + let is_sender = self.db.send_session_exists(&session_id)?; + let is_receiver = self.db.recv_session_exists(&session_id)?; match (is_sender, is_receiver) { (true, false) => self.cancel_sender_session(session_id, no_broadcast), diff --git a/payjoin-cli/src/db/v2.rs b/payjoin-cli/src/db/v2.rs index fd802179d..4d61cb7f2 100644 --- a/payjoin-cli/src/db/v2.rs +++ b/payjoin-cli/src/db/v2.rs @@ -290,6 +290,28 @@ impl Database { } Ok(session_ids) } + + /// Look up a sender session by ID regardless of active/inactive state. + pub(crate) fn send_session_exists(&self, session_id: &SessionId) -> Result { + let conn = self.get_connection()?; + let exists: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM send_sessions WHERE session_id = ?1)", + params![session_id.0], + |row| row.get(0), + )?; + Ok(exists) + } + + /// Look up a receiver session by ID regardless of active/inactive state. + pub(crate) fn recv_session_exists(&self, session_id: &SessionId) -> Result { + let conn = self.get_connection()?; + let exists: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM receive_sessions WHERE session_id = ?1)", + params![session_id.0], + |row| row.get(0), + )?; + Ok(exists) + } } #[cfg(all(test, feature = "v2"))] From 9bb3d97501f9497ca40f78502f1cb005c7fe16cd Mon Sep 17 00:00:00 2001 From: Benalleng Date: Wed, 24 Jun 2026 12:42:39 -0400 Subject: [PATCH 3/3] Add cancel again regression tests for sender and receiver --- payjoin-cli/tests/e2e.rs | 107 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 105 insertions(+), 2 deletions(-) diff --git a/payjoin-cli/tests/e2e.rs b/payjoin-cli/tests/e2e.rs index a97e23b7c..3c5aa7477 100644 --- a/payjoin-cli/tests/e2e.rs +++ b/payjoin-cli/tests/e2e.rs @@ -66,6 +66,26 @@ mod e2e { res } + /// Read all lines from `child_stdout` until EOF and return them joined by newlines. + /// Also writes every read line to tokio::io::stdout(); + async fn read_all_stdout(child_stdout: &mut tokio::process::ChildStdout) -> String { + let reader = BufReader::new(child_stdout); + let mut lines = reader.lines(); + let mut out = String::new(); + + let mut stdout = tokio::io::stdout(); + while let Some(line) = lines.next_line().await.expect("Failed to read line from stdout") { + stdout + .write_all(format!("{line}\n").as_bytes()) + .await + .expect("Failed to write to stdout"); + out.push_str(&line); + out.push('\n'); + } + + out + } + async fn send_until_request_timeout(mut cli_sender: Child) -> Result<(), BoxError> { let mut stdout = cli_sender.stdout.take().expect("failed to take stdout of child process"); let timeout = tokio::time::Duration::from_secs(35); @@ -783,6 +803,49 @@ mod e2e { "fallback tx should be in the mempool after cancel" ); + // Re-run `cancel` on the now-closed session: the command must still + // recognize the session exists and report it is already closed. + let mut cli_cancel_again = Command::new(payjoin_cli) + .arg("--root-certificate") + .arg(cert_path) + .arg("--rpchost") + .arg(&sender_rpchost) + .arg("--cookie-file") + .arg(cookie_file) + .arg("--db-path") + .arg(&sender_db_path) + .arg("--ohttp-relays") + .arg(ohttp_relays) + .arg("cancel") + .arg(session_id.to_string()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .expect("Failed to execute payjoin-cli cancel on closed session"); + + let raw_tx = sender.get_transaction(fallback_txid)?.into_model()?.tx; + let expected_hex = payjoin::bitcoin::consensus::encode::serialize_hex(&raw_tx); + + let mut cancel_again_stdout = cli_cancel_again + .stdout + .take() + .expect("failed to take stdout of cancel on closed session"); + let cancel_again_output = + tokio::time::timeout(timeout, read_all_stdout(&mut cancel_again_stdout)).await?; + terminate(cli_cancel_again) + .await + .expect("Failed to kill payjoin-cli cancel on closed session"); + + assert!( + cancel_again_output + .contains(&format!("Session {session_id} was already cancelled")), + "cancel on closed session should reference the session id and report it is already closed; got: {cancel_again_output}" + ); + assert!( + cancel_again_output.contains(&expected_hex), + "cancel on closed session should print the fallback transaction consensus hex; got: {cancel_again_output}" + ); + Ok(()) } @@ -868,8 +931,6 @@ mod e2e { .arg(ohttp_relays) .arg("cancel") .arg(session_id.to_string()) - .arg("--role") - .arg("receiver") .stdout(Stdio::piped()) .stderr(Stdio::inherit()) .spawn() @@ -894,6 +955,48 @@ mod e2e { "cancel should reference the cancelled session id" ); + // Re-run `cancel` on the now-closed session: the command must still + // recognize the session exists and report it is already closed. + let mut cli_cancel_again = Command::new(payjoin_cli) + .arg("--root-certificate") + .arg(cert_path) + .arg("--rpchost") + .arg(&receiver_rpchost) + .arg("--cookie-file") + .arg(cookie_file) + .arg("--db-path") + .arg(&receiver_db_path) + .arg("--ohttp-relays") + .arg(ohttp_relays) + .arg("cancel") + .arg(session_id.to_string()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .expect("Failed to execute payjoin-cli cancel on closed session"); + + let mut cancel_again_stdout = cli_cancel_again + .stdout + .take() + .expect("failed to take stdout of cancel on closed session"); + let cancel_again_line = tokio::time::timeout( + timeout, + wait_for_stdout_match(&mut cancel_again_stdout, |l| { + l.contains("is already closed") + }), + ) + .await?; + terminate(cli_cancel_again) + .await + .expect("Failed to kill payjoin-cli cancel on closed session"); + let cancel_again_output = cancel_again_line + .expect("cancel on closed session should report it is already closed"); + + assert!( + cancel_again_output.contains(&format!("Session {session_id} is already closed")), + "cancel on closed session should reference the session id and report it is already closed" + ); + Ok(()) }