Skip to content
Merged
9 changes: 8 additions & 1 deletion payjoin-cli/src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ pub mod wallet;
use crate::app::config::Config;
use crate::app::wallet::BitcoindWallet;
#[cfg(feature = "v2")]
use crate::cli::Role;
#[cfg(feature = "v2")]
use crate::db::v2::SessionId;

#[cfg(feature = "v1")]
Expand All @@ -31,7 +33,12 @@ pub trait App: Send + Sync {
#[cfg(feature = "v2")]
async fn history(&self) -> Result<()>;
#[cfg(feature = "v2")]
async fn cancel_sender(&self, session_id: SessionId, no_broadcast: bool) -> Result<()>;
async fn cancel(
&self,
session_id: SessionId,
no_broadcast: bool,
role: Option<Role>,
) -> Result<()>;

fn create_original_psbt(
&self,
Expand Down
3 changes: 2 additions & 1 deletion payjoin-cli/src/app/v1.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,10 +129,11 @@ impl AppTrait for App {
}

#[cfg(feature = "v2")]
async fn cancel_sender(
async fn cancel(
&self,
_session_id: crate::db::v2::SessionId,
_no_broadcast: bool,
_role: Option<crate::cli::Role>,
) -> Result<()> {
anyhow::bail!("cancel is only supported for v2 (BIP77) sessions")
}
Expand Down
137 changes: 126 additions & 11 deletions payjoin-cli/src/app/v2/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@ use payjoin::persist::{OptionalTransitionOutcome, SessionPersister};
use payjoin::receive::v2::{
replay_event_log as replay_receiver_event_log, HasReplyableError, Initialized,
MaybeInputsOwned, MaybeInputsSeen, Monitor, OutputsUnknown, PayjoinProposal,
ProvisionalProposal, ReceiveSession, Receiver, ReceiverBuilder,
SessionOutcome as ReceiverSessionOutcome, UncheckedOriginalPayload, WantsFeeRange, WantsInputs,
WantsOutputs,
PendingFallback as ReceiverPendingFallback, ProvisionalProposal, ReceiveSession, Receiver,
ReceiverBuilder, SessionOutcome as ReceiverSessionOutcome, UncheckedOriginalPayload,
WantsFeeRange, WantsInputs, WantsOutputs,
};
use payjoin::send::v2::{
replay_event_log as replay_sender_event_log, PendingFallback, PollingForProposal, SendSession,
Sender, SenderBuilder, SessionOutcome as SenderSessionOutcome, WithReplyKey,
replay_event_log as replay_sender_event_log, PendingFallback as SenderPendingFallback,
PollingForProposal, SendSession, Sender, SenderBuilder, SessionOutcome as SenderSessionOutcome,
WithReplyKey,
};
use payjoin::{ImplementationError, PjParam, Uri};
use tokio::sync::watch;
Expand All @@ -24,6 +25,7 @@ use super::wallet::BitcoindWallet;
use super::App as AppTrait;
use crate::app::v2::ohttp::{unwrap_ohttp_keys_or_else_fetch, RelayManager};
use crate::app::{handle_interrupt, http_agent};
use crate::cli::Role as CliRole;
use crate::db::v2::{ReceiverPersister, SenderPersister, SessionId};
use crate::db::Database;

Expand Down Expand Up @@ -78,6 +80,7 @@ impl StatusText for ReceiveSession {
ReceiveSession::HasReplyableError(_) =>
"Session failure, waiting to post error response",
ReceiveSession::Monitor(_) => "Monitoring payjoin proposal",
ReceiveSession::PendingFallback(_) => "Pending fallback handling",
ReceiveSession::Closed(session_outcome) => match session_outcome {
ReceiverSessionOutcome::Failure => "Session failure",
ReceiverSessionOutcome::Success(_) => "Session success, Payjoin proposal was broadcasted",
Expand Down Expand Up @@ -503,11 +506,42 @@ impl AppTrait for App {
Ok(())
}

async fn cancel_sender(&self, session_id: SessionId, no_broadcast: bool) -> Result<()> {
async fn cancel(
&self,
session_id: SessionId,
no_broadcast: bool,
role: Option<CliRole>,
) -> Result<()> {
if let Some(role) = role {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@benalleng for a note, this is the thing we may be able to remove if we're using some sort of random id for sessions. If we're using the 64 bit ShortID, there is some nonzero probability of a collision but ergonomics improve a bit. I think you mentioned running into session_id AUTOINCREMENT vs random in the Liana integration.

return match role {
CliRole::Sender => self.cancel_sender_session(session_id, no_broadcast),
CliRole::Receiver => self.cancel_receiver_session(session_id, no_broadcast),
};
}

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);

match (is_sender, is_receiver) {
(true, false) => self.cancel_sender_session(session_id, no_broadcast),
(false, true) => self.cancel_receiver_session(session_id, no_broadcast),
(true, true) => anyhow::bail!(
"Session {session_id} exists as both a sender and receiver session. \
Pass `--role sender` or `--role receiver`."
),
(false, false) => anyhow::bail!("Session {session_id} not found"),
}
}
}

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 pending: Sender<PendingFallback> = match session {
let pending: Sender<SenderPendingFallback> = match session {
SendSession::WithReplyKey(sender) => sender.cancel().save(&persister)?,
SendSession::PollingForProposal(sender) => sender.cancel().save(&persister)?,
SendSession::PendingFallback(sender) => sender,
Expand Down Expand Up @@ -540,9 +574,72 @@ impl AppTrait for App {
pending.close().save(&persister)?;
Ok(())
}
}

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 pending: Receiver<ReceiverPendingFallback> = match session {
ReceiveSession::Initialized(receiver) => {
receiver.cancel().save(&persister)?;
println!("Session {session_id} cancelled. No fallback transaction to broadcast.");
return Ok(());
}
ReceiveSession::UncheckedOriginalPayload(receiver) => {
receiver.cancel().save(&persister)?;
println!("Session {session_id} cancelled. No fallback transaction to broadcast.");
return Ok(());
}
ReceiveSession::MaybeInputsOwned(receiver) => receiver.cancel().save(&persister)?,
ReceiveSession::MaybeInputsSeen(receiver) => receiver.cancel().save(&persister)?,
ReceiveSession::OutputsUnknown(receiver) => receiver.cancel().save(&persister)?,
ReceiveSession::WantsOutputs(receiver) => receiver.cancel().save(&persister)?,
ReceiveSession::WantsInputs(receiver) => receiver.cancel().save(&persister)?,
ReceiveSession::WantsFeeRange(receiver) => receiver.cancel().save(&persister)?,
ReceiveSession::ProvisionalProposal(receiver) => receiver.cancel().save(&persister)?,
ReceiveSession::PayjoinProposal(receiver) => receiver.cancel().save(&persister)?,
ReceiveSession::Monitor(receiver) => receiver.cancel().save(&persister)?,
ReceiveSession::HasReplyableError(receiver) => match receiver
.cancel()
.save(&persister)?
{
Some(pending) => pending,
None => {
println!("Session {session_id} cancelled. No fallback transaction available.");
return Ok(());
}
},
ReceiveSession::PendingFallback(receiver) => receiver,
ReceiveSession::Closed(
ReceiverSessionOutcome::Success(_)
| ReceiverSessionOutcome::FallbackBroadcasted
| ReceiverSessionOutcome::PayjoinProposalSent,
) => {
println!("Session {session_id} already completed successfully. Cannot cancel.");
return Ok(());
}
ReceiveSession::Closed(_) => {
println!("Session {session_id} is already closed. Nothing left to do.");
return Ok(());
}
};

if no_broadcast {
println!(
"Session {session_id} cancelled. Broadcast the fallback 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(())
}

fn close_failed_session<P>(persister: &P, session_id: &SessionId, role: &str)
where
P: SessionPersister,
Expand Down Expand Up @@ -576,7 +673,7 @@ impl App {
SendSession::PendingFallback(_) => {
let id = persister.session_id();
println!(
"Session {id} was cancelled. Run `payjoin-cli cancel {id}` to cancel and broadcast the original transaction."
"Session {id} was cancelled. Run `payjoin-cli cancel {id}` to cancel and broadcast the fallback transaction."
);
return Ok(());
}
Expand Down Expand Up @@ -690,6 +787,13 @@ impl App {
self.handle_error(error, persister).await,
ReceiveSession::Monitor(proposal) =>
self.monitor_payjoin_proposal(proposal, persister).await,
ReceiveSession::PendingFallback(_) => {
let id = persister.session_id();
println!(
"Session {id} was cancelled. Run `payjoin-cli cancel {id}` to cancel and broadcast the fallback transaction."
);
return Ok(());
}
ReceiveSession::Closed(_) => return Err(anyhow!("Session closed")),
}
};
Expand Down Expand Up @@ -932,7 +1036,18 @@ impl App {
};

if let Err(e) = session.process_error_response(&err_bytes, err_ctx).save(persister) {
return Err(anyhow!("Failed to process error response: {}", e));
if let Some(api_err) = e.api_error_ref() {
tracing::warn!("Failed to confirm error response delivery: {api_err}");
}
match e.error_state() {
Some(_) => {
let id = persister.session_id();
println!(
"Session {id} failed. Run `payjoin-cli cancel {id}` to cancel and broadcast the fallback transaction."
);
}
None => return Err(anyhow!("Failed to process error response")),
}
}

Ok(())
Expand Down
14 changes: 12 additions & 2 deletions payjoin-cli/src/cli/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use std::path::PathBuf;

use clap::{value_parser, Parser, Subcommand};
use clap::{value_parser, Parser, Subcommand, ValueEnum};
use payjoin::bitcoin::amount::ParseAmountError;
use payjoin::bitcoin::{Amount, FeeRate};
use payjoin::Url;
Expand Down Expand Up @@ -133,7 +133,7 @@ pub enum Commands {
/// Show payjoin session history
History,
#[cfg(feature = "v2")]
/// Cancel a sender session, broadcasting the fallback transaction by default (BIP77/v2 only)
/// Cancel a sender or receiver session, broadcasting the fallback transaction by default (BIP77/v2 only)
Cancel {
/// The session ID to cancel
#[arg(required = true)]
Expand All @@ -142,9 +142,19 @@ pub enum Commands {
/// Cancel without broadcasting the fallback transaction
#[arg(long = "no-broadcast")]
no_broadcast: bool,

/// The session role to cancel
#[arg(long = "role", value_enum)]
role: Option<Role>,
},
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
pub enum Role {
Sender,
Receiver,
}

pub fn parse_amount_in_sat(s: &str) -> Result<Amount, ParseAmountError> {
Amount::from_str_in(s, payjoin::bitcoin::Denomination::Satoshi)
}
Expand Down
2 changes: 2 additions & 0 deletions payjoin-cli/src/db/v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ impl ReceiverPersister {
}

pub fn from_id(db: Arc<Database>, id: SessionId) -> Self { Self { db, session_id: id } }

pub fn session_id(&self) -> SessionId { self.session_id.clone() }
}

impl SessionPersister for ReceiverPersister {
Expand Down
4 changes: 2 additions & 2 deletions payjoin-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,8 @@ async fn main() -> Result<()> {
app.history().await?;
}
#[cfg(feature = "v2")]
Commands::Cancel { session_id, no_broadcast } => {
app.cancel_sender(SessionId(*session_id), *no_broadcast).await?;
Commands::Cancel { session_id, no_broadcast, role } => {
app.cancel(SessionId(*session_id), *no_broadcast, *role).await?;
}
};

Expand Down
Loading
Loading