Skip to content

Commit 3993880

Browse files
committed
cli: Add receiver support to cancel command
1 parent ea94ce6 commit 3993880

7 files changed

Lines changed: 250 additions & 18 deletions

File tree

payjoin-cli/src/app/mod.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ pub mod wallet;
1111
use crate::app::config::Config;
1212
use crate::app::wallet::BitcoindWallet;
1313
#[cfg(feature = "v2")]
14+
use crate::cli::Role;
15+
#[cfg(feature = "v2")]
1416
use crate::db::v2::SessionId;
1517

1618
#[cfg(feature = "v1")]
@@ -31,7 +33,12 @@ pub trait App: Send + Sync {
3133
#[cfg(feature = "v2")]
3234
async fn history(&self) -> Result<()>;
3335
#[cfg(feature = "v2")]
34-
async fn cancel_sender(&self, session_id: SessionId, no_broadcast: bool) -> Result<()>;
36+
async fn cancel(
37+
&self,
38+
session_id: SessionId,
39+
no_broadcast: bool,
40+
role: Option<Role>,
41+
) -> Result<()>;
3542

3643
fn create_original_psbt(
3744
&self,

payjoin-cli/src/app/v1.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,10 +129,11 @@ impl AppTrait for App {
129129
}
130130

131131
#[cfg(feature = "v2")]
132-
async fn cancel_sender(
132+
async fn cancel(
133133
&self,
134134
_session_id: crate::db::v2::SessionId,
135135
_no_broadcast: bool,
136+
_role: Option<crate::cli::Role>,
136137
) -> Result<()> {
137138
anyhow::bail!("cancel is only supported for v2 (BIP77) sessions")
138139
}

payjoin-cli/src/app/v2/mod.rs

Lines changed: 113 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@ use payjoin::persist::{OptionalTransitionOutcome, SessionPersister};
88
use payjoin::receive::v2::{
99
replay_event_log as replay_receiver_event_log, HasReplyableError, Initialized,
1010
MaybeInputsOwned, MaybeInputsSeen, Monitor, OutputsUnknown, PayjoinProposal,
11-
ProvisionalProposal, ReceiveSession, Receiver, ReceiverBuilder,
12-
SessionOutcome as ReceiverSessionOutcome, UncheckedOriginalPayload, WantsFeeRange, WantsInputs,
13-
WantsOutputs,
11+
PendingFallback as ReceiverPendingFallback, ProvisionalProposal, ReceiveSession, Receiver,
12+
ReceiverBuilder, SessionOutcome as ReceiverSessionOutcome, UncheckedOriginalPayload,
13+
WantsFeeRange, WantsInputs, WantsOutputs,
1414
};
1515
use payjoin::send::v2::{
16-
replay_event_log as replay_sender_event_log, PendingFallback, PollingForProposal, SendSession,
17-
Sender, SenderBuilder, SessionOutcome as SenderSessionOutcome, WithReplyKey,
16+
replay_event_log as replay_sender_event_log, PendingFallback as SenderPendingFallback,
17+
PollingForProposal, SendSession, Sender, SenderBuilder, SessionOutcome as SenderSessionOutcome,
18+
WithReplyKey,
1819
};
1920
use payjoin::{ImplementationError, PjParam, Uri};
2021
use tokio::sync::watch;
@@ -24,6 +25,7 @@ use super::wallet::BitcoindWallet;
2425
use super::App as AppTrait;
2526
use crate::app::v2::ohttp::{unwrap_ohttp_keys_or_else_fetch, RelayManager};
2627
use crate::app::{handle_interrupt, http_agent};
28+
use crate::cli::Role as CliRole;
2729
use crate::db::v2::{ReceiverPersister, SenderPersister, SessionId};
2830
use crate::db::Database;
2931

@@ -486,11 +488,42 @@ impl AppTrait for App {
486488
Ok(())
487489
}
488490

489-
async fn cancel_sender(&self, session_id: SessionId, no_broadcast: bool) -> Result<()> {
491+
async fn cancel(
492+
&self,
493+
session_id: SessionId,
494+
no_broadcast: bool,
495+
role: Option<CliRole>,
496+
) -> Result<()> {
497+
if let Some(role) = role {
498+
return match role {
499+
CliRole::Sender => self.cancel_sender_session(session_id, no_broadcast),
500+
CliRole::Receiver => self.cancel_receiver_session(session_id, no_broadcast),
501+
};
502+
}
503+
504+
let send_ids = self.db.get_send_session_ids()?;
505+
let recv_ids = self.db.get_recv_session_ids()?;
506+
let is_sender = send_ids.iter().any(|id| id.0 == session_id.0);
507+
let is_receiver = recv_ids.iter().any(|id| id.0 == session_id.0);
508+
509+
match (is_sender, is_receiver) {
510+
(true, false) => self.cancel_sender_session(session_id, no_broadcast),
511+
(false, true) => self.cancel_receiver_session(session_id, no_broadcast),
512+
(true, true) => anyhow::bail!(
513+
"Session {session_id} exists as both a sender and receiver session. \
514+
Pass `--role sender` or `--role receiver`."
515+
),
516+
(false, false) => anyhow::bail!("Session {session_id} not found"),
517+
}
518+
}
519+
}
520+
521+
impl App {
522+
fn cancel_sender_session(&self, session_id: SessionId, no_broadcast: bool) -> Result<()> {
490523
let persister = SenderPersister::from_id(self.db.clone(), session_id.clone());
491524
let (session, _history) = replay_sender_event_log(&persister)?;
492525

493-
let pending: Sender<PendingFallback> = match session {
526+
let pending: Sender<SenderPendingFallback> = match session {
494527
SendSession::WithReplyKey(sender) => sender.cancel().save(&persister)?,
495528
SendSession::PollingForProposal(sender) => sender.cancel().save(&persister)?,
496529
SendSession::PendingFallback(sender) => sender,
@@ -523,9 +556,72 @@ impl AppTrait for App {
523556
pending.close().save(&persister)?;
524557
Ok(())
525558
}
526-
}
527559

528-
impl App {
560+
fn cancel_receiver_session(&self, session_id: SessionId, no_broadcast: bool) -> Result<()> {
561+
let persister = ReceiverPersister::from_id(self.db.clone(), session_id.clone());
562+
let (session, _history) = replay_receiver_event_log(&persister)?;
563+
564+
let pending: Receiver<ReceiverPendingFallback> = match session {
565+
ReceiveSession::Initialized(receiver) => {
566+
receiver.cancel().save(&persister)?;
567+
println!("Session {session_id} cancelled. No fallback transaction to broadcast.");
568+
return Ok(());
569+
}
570+
ReceiveSession::UncheckedOriginalPayload(receiver) => {
571+
receiver.cancel().save(&persister)?;
572+
println!("Session {session_id} cancelled. No fallback transaction to broadcast.");
573+
return Ok(());
574+
}
575+
ReceiveSession::MaybeInputsOwned(receiver) => receiver.cancel().save(&persister)?,
576+
ReceiveSession::MaybeInputsSeen(receiver) => receiver.cancel().save(&persister)?,
577+
ReceiveSession::OutputsUnknown(receiver) => receiver.cancel().save(&persister)?,
578+
ReceiveSession::WantsOutputs(receiver) => receiver.cancel().save(&persister)?,
579+
ReceiveSession::WantsInputs(receiver) => receiver.cancel().save(&persister)?,
580+
ReceiveSession::WantsFeeRange(receiver) => receiver.cancel().save(&persister)?,
581+
ReceiveSession::ProvisionalProposal(receiver) => receiver.cancel().save(&persister)?,
582+
ReceiveSession::PayjoinProposal(receiver) => receiver.cancel().save(&persister)?,
583+
ReceiveSession::Monitor(receiver) => receiver.cancel().save(&persister)?,
584+
ReceiveSession::HasReplyableError(receiver) => match receiver
585+
.cancel()
586+
.save(&persister)?
587+
{
588+
Some(pending) => pending,
589+
None => {
590+
println!("Session {session_id} cancelled. No fallback transaction available.");
591+
return Ok(());
592+
}
593+
},
594+
ReceiveSession::PendingFallback(receiver) => receiver,
595+
ReceiveSession::Closed(
596+
ReceiverSessionOutcome::Success(_)
597+
| ReceiverSessionOutcome::FallbackBroadcasted
598+
| ReceiverSessionOutcome::PayjoinProposalSent,
599+
) => {
600+
println!("Session {session_id} already completed successfully. Cannot cancel.");
601+
return Ok(());
602+
}
603+
ReceiveSession::Closed(_) => {
604+
println!("Session {session_id} is already closed. Nothing left to do.");
605+
return Ok(());
606+
}
607+
};
608+
609+
if no_broadcast {
610+
println!(
611+
"Session {session_id} cancelled. Broadcast the fallback transaction manually:\n{}",
612+
serialize_hex(pending.fallback_tx())
613+
);
614+
} else {
615+
self.wallet().broadcast_tx(pending.fallback_tx())?;
616+
println!(
617+
"Broadcasted fallback transaction txid: {}",
618+
pending.fallback_tx().compute_txid()
619+
);
620+
}
621+
pending.close().save(&persister)?;
622+
Ok(())
623+
}
624+
529625
fn close_failed_session<P>(persister: &P, session_id: &SessionId, role: &str)
530626
where
531627
P: SessionPersister,
@@ -559,7 +655,7 @@ impl App {
559655
SendSession::PendingFallback(_) => {
560656
let id = persister.session_id();
561657
println!(
562-
"Session {id} was cancelled. Run `payjoin-cli cancel {id}` to cancel and broadcast the original transaction."
658+
"Session {id} was cancelled. Run `payjoin-cli cancel {id}` to cancel and broadcast the fallback transaction."
563659
);
564660
return Ok(());
565661
}
@@ -674,8 +770,13 @@ impl App {
674770
self.handle_error(error, persister).await,
675771
ReceiveSession::Monitor(proposal) =>
676772
self.monitor_payjoin_proposal(proposal, persister).await,
677-
ReceiveSession::PendingFallback(_) =>
678-
return Err(anyhow!("PendingFallback session handling not yet implemented")),
773+
ReceiveSession::PendingFallback(_) => {
774+
let id = persister.session_id();
775+
println!(
776+
"Session {id} was cancelled. Run `payjoin-cli cancel {id}` to cancel and broadcast the fallback transaction."
777+
);
778+
return Ok(());
779+
}
679780
ReceiveSession::Closed(_) => return Err(anyhow!("Session closed")),
680781
}
681782
};

payjoin-cli/src/cli/mod.rs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use std::path::PathBuf;
22

3-
use clap::{value_parser, Parser, Subcommand};
3+
use clap::{value_parser, Parser, Subcommand, ValueEnum};
44
use payjoin::bitcoin::amount::ParseAmountError;
55
use payjoin::bitcoin::{Amount, FeeRate};
66
use payjoin::Url;
@@ -133,7 +133,7 @@ pub enum Commands {
133133
/// Show payjoin session history
134134
History,
135135
#[cfg(feature = "v2")]
136-
/// Cancel a sender session, broadcasting the fallback transaction by default (BIP77/v2 only)
136+
/// Cancel a sender or receiver session, broadcasting the fallback transaction by default (BIP77/v2 only)
137137
Cancel {
138138
/// The session ID to cancel
139139
#[arg(required = true)]
@@ -142,9 +142,19 @@ pub enum Commands {
142142
/// Cancel without broadcasting the fallback transaction
143143
#[arg(long = "no-broadcast")]
144144
no_broadcast: bool,
145+
146+
/// The session role to cancel
147+
#[arg(long = "role", value_enum)]
148+
role: Option<Role>,
145149
},
146150
}
147151

152+
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
153+
pub enum Role {
154+
Sender,
155+
Receiver,
156+
}
157+
148158
pub fn parse_amount_in_sat(s: &str) -> Result<Amount, ParseAmountError> {
149159
Amount::from_str_in(s, payjoin::bitcoin::Denomination::Satoshi)
150160
}

payjoin-cli/src/db/v2.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,8 @@ impl ReceiverPersister {
141141
}
142142

143143
pub fn from_id(db: Arc<Database>, id: SessionId) -> Self { Self { db, session_id: id } }
144+
145+
pub fn session_id(&self) -> SessionId { self.session_id.clone() }
144146
}
145147

146148
impl SessionPersister for ReceiverPersister {

payjoin-cli/src/main.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,8 @@ async fn main() -> Result<()> {
7979
app.history().await?;
8080
}
8181
#[cfg(feature = "v2")]
82-
Commands::Cancel { session_id, no_broadcast } => {
83-
app.cancel_sender(SessionId(*session_id), *no_broadcast).await?;
82+
Commands::Cancel { session_id, no_broadcast, role } => {
83+
app.cancel(SessionId(*session_id), *no_broadcast, *role).await?;
8484
}
8585
};
8686

payjoin-cli/tests/e2e.rs

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -754,4 +754,115 @@ mod e2e {
754754

755755
Ok(())
756756
}
757+
758+
#[cfg(feature = "v2")]
759+
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
760+
async fn receiver_cancel_v2() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
761+
use payjoin_test_utils::{init_tracing, TestServices};
762+
use tempfile::TempDir;
763+
764+
type Result<T> = std::result::Result<T, BoxError>;
765+
766+
init_tracing();
767+
let mut services = TestServices::initialize().await?;
768+
let temp_dir = tempdir()?;
769+
770+
let result = tokio::select! {
771+
res = services.take_ohttp_relay_handle() => Err(format!("Ohttp relay is long running: {res:?}").into()),
772+
res = services.take_directory_handle() => Err(format!("Directory server is long running: {res:?}").into()),
773+
res = cancel_cli_async(&services, &temp_dir) => res,
774+
};
775+
776+
assert!(result.is_ok(), "receiver_cancel_v2 failed: {:#?}", result.unwrap_err());
777+
778+
async fn cancel_cli_async(services: &TestServices, temp_dir: &TempDir) -> Result<()> {
779+
let receiver_db_path = temp_dir.path().join("receiver_db");
780+
let (bitcoind, _sender, _receiver) = init_bitcoind_sender_receiver(None, None)?;
781+
let cert_path = &temp_dir.path().join("localhost.der");
782+
tokio::fs::write(cert_path, services.cert()).await?;
783+
services.wait_for_services_ready().await?;
784+
let ohttp_keys = services.fetch_ohttp_keys().await?;
785+
let ohttp_keys_path = temp_dir.path().join("ohttp_keys");
786+
tokio::fs::write(&ohttp_keys_path, ohttp_keys.encode()?).await?;
787+
788+
let receiver_rpchost = format!("http://{}/wallet/receiver", bitcoind.params.rpc_socket);
789+
let cookie_file = &bitcoind.params.cookie_file;
790+
let payjoin_cli = env!("CARGO_BIN_EXE_payjoin-cli");
791+
let directory = &services.directory_url();
792+
let ohttp_relay = &services.ohttp_relay_url();
793+
794+
// Start a receiver and capture its BIP21 so a session is persisted,
795+
// then leave it parked at Initialized waiting for a proposal.
796+
let cli_receiver = Command::new(payjoin_cli)
797+
.arg("--root-certificate")
798+
.arg(cert_path)
799+
.arg("--rpchost")
800+
.arg(&receiver_rpchost)
801+
.arg("--cookie-file")
802+
.arg(cookie_file)
803+
.arg("--db-path")
804+
.arg(&receiver_db_path)
805+
.arg("--ohttp-relays")
806+
.arg(ohttp_relay)
807+
.arg("receive")
808+
.arg(RECEIVE_SATS)
809+
.arg("--pj-directory")
810+
.arg(directory)
811+
.arg("--ohttp-keys")
812+
.arg(&ohttp_keys_path)
813+
.stdout(Stdio::piped())
814+
.stderr(Stdio::inherit())
815+
.spawn()
816+
.expect("Failed to execute payjoin-cli receiver");
817+
let _bip21 = get_bip21_from_receiver(cli_receiver).await;
818+
819+
// There is only one receiver session in progress.
820+
let session_id = 1i64;
821+
822+
// Run `payjoin-cli cancel <session-id> --role receiver`: the session is at
823+
// Initialized so there is no fallback transaction to broadcast.
824+
let mut cli_cancel = Command::new(payjoin_cli)
825+
.arg("--root-certificate")
826+
.arg(cert_path)
827+
.arg("--rpchost")
828+
.arg(&receiver_rpchost)
829+
.arg("--cookie-file")
830+
.arg(cookie_file)
831+
.arg("--db-path")
832+
.arg(&receiver_db_path)
833+
.arg("--ohttp-relays")
834+
.arg(ohttp_relay)
835+
.arg("cancel")
836+
.arg(session_id.to_string())
837+
.arg("--role")
838+
.arg("receiver")
839+
.stdout(Stdio::piped())
840+
.stderr(Stdio::inherit())
841+
.spawn()
842+
.expect("Failed to execute payjoin-cli cancel");
843+
844+
let mut cancel_stdout =
845+
cli_cancel.stdout.take().expect("failed to take stdout of cancel");
846+
let timeout = tokio::time::Duration::from_secs(10);
847+
let cancel_line = tokio::time::timeout(
848+
timeout,
849+
wait_for_stdout_match(&mut cancel_stdout, |l| {
850+
l.contains("No fallback transaction to broadcast")
851+
}),
852+
)
853+
.await?;
854+
terminate(cli_cancel).await.expect("Failed to kill payjoin-cli cancel");
855+
let subcommand_output =
856+
cancel_line.expect("cancel should report no fallback transaction");
857+
858+
assert!(
859+
subcommand_output.contains(&format!("Session {session_id} cancelled")),
860+
"cancel should reference the cancelled session id"
861+
);
862+
863+
Ok(())
864+
}
865+
866+
Ok(())
867+
}
757868
}

0 commit comments

Comments
 (0)