Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions payjoin-cli/src/app/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,8 @@ fn handle_subcommands(config: Builder, cli: &Cli) -> Result<Builder, ConfigError
Commands::History => Ok(config),
#[cfg(feature = "v2")]
Commands::Fallback { .. } => Ok(config),
#[cfg(feature = "v2")]
Commands::Cancel { .. } => Ok(config),
}
}

Expand Down
2 changes: 2 additions & 0 deletions payjoin-cli/src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ pub trait App: Send + Sync {
async fn history(&self) -> Result<()>;
#[cfg(feature = "v2")]
async fn fallback_sender(&self, session_id: SessionId) -> Result<()>;
#[cfg(feature = "v2")]
async fn cancel_sender(&self, session_id: SessionId) -> Result<()>;

fn create_original_psbt(
&self,
Expand Down
5 changes: 5 additions & 0 deletions payjoin-cli/src/app/v1.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,11 @@ impl AppTrait for App {
async fn fallback_sender(&self, _session_id: crate::db::v2::SessionId) -> Result<()> {
anyhow::bail!("fallback is only supported for v2 (BIP77) sessions")
}

#[cfg(feature = "v2")]
async fn cancel_sender(&self, _session_id: crate::db::v2::SessionId) -> Result<()> {
anyhow::bail!("cancel is only supported for v2 (BIP77) sessions")
}
}

impl App {
Expand Down
89 changes: 68 additions & 21 deletions payjoin-cli/src/app/v2/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -55,8 +55,9 @@ impl StatusText for SendSession {
SendSession::Closed(session_outcome) => match session_outcome {
SenderSessionOutcome::Failure => "Session failure",
SenderSessionOutcome::Success(_) => "Session success",
SenderSessionOutcome::Cancel => "Session cancelled",
SenderSessionOutcome::Aborted => "Session aborted",
},
SendSession::PendingFallback(_) => "Session aborted",
}
}
}
Expand Down Expand Up @@ -486,29 +487,71 @@ impl AppTrait for App {

async fn fallback_sender(&self, session_id: SessionId) -> Result<()> {
let persister = SenderPersister::from_id(self.db.clone(), session_id.clone());
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();
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 <hex>`."
);
return Ok(());
}
let (session, _history) = replay_sender_event_log(&persister)?;

let fallback_tx = history.fallback_tx();
self.wallet().broadcast_tx(&fallback_tx)?;
println!("Broadcasted fallback transaction txid: {}", fallback_tx.compute_txid());
let pending: Sender<PendingFallback> = match session {
SendSession::PendingFallback(sender) => sender,
SendSession::WithReplyKey(sender) => sender.cancel().save(&persister)?,
SendSession::PollingForProposal(sender) => sender.cancel().save(&persister)?,
SendSession::Closed(SenderSessionOutcome::Success(proposal)) => {
let txid = proposal.extract_tx_unchecked_fee_rate().compute_txid();
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 <hex>`."
);
return Ok(());
}
SendSession::Closed(_) => {
println!("Session {session_id} is already closed. Nothing left to do.");
return Ok(());
}
};

self.wallet().broadcast_tx(pending.fallback_tx())?;
println!("Broadcasted fallback transaction txid: {}", pending.fallback_tx().compute_txid());

if let Err(e) = SessionPersister::close(&persister) {
tracing::warn!("Failed to close session {session_id} after fallback: {e}");
}
Ok(())
}

async fn cancel_sender(&self, session_id: SessionId) -> Result<()> {
let persister = SenderPersister::from_id(self.db.clone(), session_id.clone());
let (session, _history) = replay_sender_event_log(&persister)?;

match session {
SendSession::WithReplyKey(sender) => {
sender.cancel().save(&persister)?;
}
SendSession::PollingForProposal(sender) => {
sender.cancel().save(&persister)?;
}
SendSession::PendingFallback(_) => {
println!("Session {session_id} is already cancelled.");
return Ok(());
}
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.");
return Ok(());
}
}
println!(
"Session {session_id} cancelled. Run `payjoin-cli fallback {session_id}` to broadcast the original transaction."
);
Ok(())
}
}

impl App {
Expand Down Expand Up @@ -538,10 +581,14 @@ impl App {
return Ok(());
}
SendSession::Closed(SenderSessionOutcome::Failure)
| SendSession::Closed(SenderSessionOutcome::Cancel) => {
| SendSession::Closed(SenderSessionOutcome::Aborted) => {
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 fallback {id}` to broadcast the original transaction."
);
return Ok(());
}
Expand Down
7 changes: 7 additions & 0 deletions payjoin-cli/src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,13 @@ pub enum Commands {
#[arg(required = true)]
session_id: i64,
},
#[cfg(feature = "v2")]
/// Cancel a sender session and broadcast the fallback transaction (BIP77/v2 only)
Cancel {
/// The session ID to cancel
#[arg(required = true)]
session_id: i64,
},
}

pub fn parse_amount_in_sat(s: &str) -> Result<Amount, ParseAmountError> {
Expand Down
4 changes: 4 additions & 0 deletions payjoin-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ async fn main() -> Result<()> {
Commands::Fallback { session_id } => {
app.fallback_sender(SessionId(*session_id)).await?;
}
#[cfg(feature = "v2")]
Commands::Cancel { session_id } => {
app.cancel_sender(SessionId(*session_id)).await?;
}
};

Ok(())
Expand Down
37 changes: 36 additions & 1 deletion payjoin-cli/tests/e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -712,7 +712,42 @@ mod e2e {
// 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");
assert_eq!(mempool_size, 0, "fallback should not be in mempool before cancel");

// Run `payjoin-cli cancel <session-id>` and assert session is cancelled without broadcast
let mut cli_cancel = 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_relay)
.arg("cancel")
.arg(session_id.to_string())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()
.expect("Failed to execute payjoin-cli cancel");

let mut cancel_stdout =
cli_cancel.stdout.take().expect("failed to take stdout of cancel");
let timeout = tokio::time::Duration::from_secs(10);
let cancel_line = tokio::time::timeout(
timeout,
wait_for_stdout_match(&mut cancel_stdout, |l| l.contains("cancelled")),
)
.await?;
terminate(cli_cancel).await.expect("Failed to kill payjoin-cli cancel");
assert!(cancel_line.is_some(), "cancel should output cancellation confirmation");

// Ensure the fallback was NOT broadcast after cancel
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 after cancel");

// Run `payjoin-cli fallback <session-id>` and assert broadcast
let mut cli_fallback = Command::new(payjoin_cli)
Expand Down
16 changes: 8 additions & 8 deletions payjoin-ffi/csharp/UnitTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -211,13 +211,13 @@ 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<SendSession.Closed>(state);
Assert.IsType<SendSession.Cancelled>(state);
}

[Fact]
Expand All @@ -238,13 +238,13 @@ 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<SendSession.Closed>(state);
Assert.IsType<SendSession.Cancelled>(state);
}
}

Expand Down
20 changes: 10 additions & 10 deletions payjoin-ffi/dart/test/test_payjoin_unit_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -183,14 +183,14 @@ 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));
var pendingFallback = cancelTransition.save(persister: sender_persister);
expect(pendingFallback, isNotNull);
expect(pendingFallback!.fallbackTx().length, greaterThan(0));
final result = payjoin.replaySenderEventLog(persister: sender_persister);
expect(
result.state(),
isA<payjoin.ClosedSendSession>(),
reason: "sender should be in Closed state after cancel",
isA<payjoin.CancelledSendSession>(),
reason: "sender should be in Cancelled state after cancel",
);
});

Expand All @@ -215,18 +215,18 @@ 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));
expect(pendingFallback, isNotNull);
expect(pendingFallback!.fallbackTx().length, greaterThan(0));
final result = await payjoin.replaySenderEventLogAsync(
persister: sender_persister,
);
expect(
result.state(),
isA<payjoin.ClosedSendSession>(),
reason: "sender should be in Closed state after cancel",
isA<payjoin.CancelledSendSession>(),
reason: "sender should be in Cancelled state after cancel",
);
});
});
Expand Down
21 changes: 11 additions & 10 deletions payjoin-ffi/javascript/test/unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,19 +237,19 @@ describe("Sender cancel tests", () => {
.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();
assert.strictEqual(
state.tag,
"Closed",
"State should be Closed after cancel",
"Cancelled",
"State should be Cancelled after cancel",
);
});

Expand Down Expand Up @@ -285,19 +285,20 @@ describe("Sender cancel tests", () => {
.saveAsync(senderPersister);

const cancelTransition = withReplyKey.cancel();
const fallbackTx = await cancelTransition.saveAsync(senderPersister);
assert.ok(fallbackTx, "fallback tx should be returned");
const pendingFallback =
await cancelTransition.saveAsync(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 = await payjoin.replaySenderEventLogAsync(senderPersister);
const state = result.state();
assert.strictEqual(
state.tag,
"Closed",
"State should be Closed after cancel",
"Cancelled",
"State should be Cancelled after cancel",
);
});
});
Expand Down
16 changes: 8 additions & 8 deletions payjoin-ffi/python/test/test_payjoin_unit_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,11 +214,11 @@ 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_CLOSED())
self.assertTrue(result.state().is_CANCELLED())


class TestSenderCancelAsync(unittest.TestCase):
Expand Down Expand Up @@ -251,11 +251,11 @@ 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_CLOSED())
self.assertTrue(result.state().is_CANCELLED())

asyncio.run(run_test())

Expand Down
Loading
Loading