diff --git a/payjoin-cli/src/app/v2/mod.rs b/payjoin-cli/src/app/v2/mod.rs index 8e7af2287..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), @@ -546,7 +544,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 +558,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 +585,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 +626,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(()); } }; 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"))] 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(()) }