Skip to content

Commit 46f26b0

Browse files
authored
Demo sender fallback (#1510)
2 parents 89dcd9d + a927035 commit 46f26b0

8 files changed

Lines changed: 284 additions & 53 deletions

File tree

payjoin-cli/src/app/config.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,8 @@ fn handle_subcommands(config: Builder, cli: &Cli) -> Result<Builder, ConfigError
336336
Commands::Resume => Ok(config),
337337
#[cfg(feature = "v2")]
338338
Commands::History => Ok(config),
339+
#[cfg(feature = "v2")]
340+
Commands::Fallback { .. } => Ok(config),
339341
}
340342
}
341343

payjoin-cli/src/app/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ pub mod config;
1010
pub mod wallet;
1111
use crate::app::config::Config;
1212
use crate::app::wallet::BitcoindWallet;
13+
#[cfg(feature = "v2")]
14+
use crate::db::v2::SessionId;
1315

1416
#[cfg(feature = "v1")]
1517
pub(crate) mod v1;
@@ -28,6 +30,8 @@ pub trait App: Send + Sync {
2830
async fn resume_payjoins(&self) -> Result<()>;
2931
#[cfg(feature = "v2")]
3032
async fn history(&self) -> Result<()>;
33+
#[cfg(feature = "v2")]
34+
async fn fallback_sender(&self, session_id: SessionId) -> Result<()>;
3135

3236
fn create_original_psbt(
3337
&self,

payjoin-cli/src/app/v1.rs

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
use std::collections::HashMap;
22
use std::net::SocketAddr;
3-
use std::str::FromStr;
43
use std::sync::Arc;
54

65
use anyhow::{anyhow, Context, Result};
@@ -11,7 +10,7 @@ use hyper::server::conn::http1;
1110
use hyper::service::service_fn;
1211
use hyper::{Method, Request, Response, StatusCode};
1312
use hyper_util::rt::TokioIo;
14-
use payjoin::bitcoin::psbt::Psbt;
13+
use payjoin::bitcoin::consensus::encode::serialize_hex;
1514
use payjoin::bitcoin::{Amount, FeeRate};
1615
use payjoin::receive::v1::{PayjoinProposal, UncheckedOriginalPayload};
1716
use payjoin::receive::Error;
@@ -64,32 +63,44 @@ impl AppTrait for App {
6463
let uri = uri.check_pj_supported().map_err(|_| anyhow!("URI does not support Payjoin"))?;
6564
let amount = uri.amount.ok_or_else(|| anyhow!("please specify the amount in the Uri"))?;
6665
let psbt = self.create_original_psbt(&uri.address, amount, fee_rate)?;
66+
let fallback_tx = psbt.clone().extract_tx()?;
6767
let (req, ctx) = SenderBuilder::new(psbt, uri.clone())
6868
.build_recommended(fee_rate)
6969
.with_context(|| "Failed to build payjoin request")?
7070
.create_v1_post_request();
7171
let http = http_agent(&self.config)?;
7272
let body = String::from_utf8(req.body.clone()).unwrap();
7373
println!("Sending Original PSBT to {}", req.url);
74-
let response = http
74+
let response = match http
7575
.post(req.url)
7676
.header("Content-Type", req.content_type)
7777
.body(body.clone())
7878
.send()
7979
.await
80-
.with_context(|| "HTTP request failed")?;
81-
let fallback_tx = Psbt::from_str(&body)
82-
.map_err(|e| anyhow!("Failed to load PSBT from base64: {}", e))?
83-
.extract_tx()?;
84-
println!("Fallback transaction txid: {}", fallback_tx.compute_txid());
85-
println!(
86-
"Fallback transaction hex: {:#}",
87-
payjoin::bitcoin::consensus::encode::serialize_hex(&fallback_tx)
88-
);
89-
let psbt = ctx.process_response(&response.bytes().await?).map_err(|e| {
90-
tracing::debug!("Error processing response: {e:?}");
91-
anyhow!("Failed to process response {e}")
92-
})?;
80+
{
81+
Ok(response) => response,
82+
Err(e) => {
83+
tracing::error!("HTTP request failed: {e}");
84+
println!("Payjoin failed. To broadcast the fallback transaction, run:");
85+
println!(
86+
" bitcoin-cli -rpcwallet=<wallet> sendrawtransaction {:#}",
87+
serialize_hex(&fallback_tx)
88+
);
89+
return Err(anyhow!("HTTP request failed: {e}"));
90+
}
91+
};
92+
let psbt = match ctx.process_response(&response.bytes().await?) {
93+
Ok(psbt) => psbt,
94+
Err(e) => {
95+
tracing::error!("Error processing response: {e:?}");
96+
println!("Payjoin failed. To broadcast the fallback transaction, run:");
97+
println!(
98+
" bitcoin-cli -rpcwallet=<wallet> sendrawtransaction {:#}",
99+
serialize_hex(&fallback_tx)
100+
);
101+
return Err(anyhow!("Failed to process response {e}"));
102+
}
103+
};
93104

94105
self.process_pj_response(psbt)?;
95106
Ok(())
@@ -116,6 +127,11 @@ impl AppTrait for App {
116127
async fn history(&self) -> Result<()> {
117128
unimplemented!("history not implemented for v1");
118129
}
130+
131+
#[cfg(feature = "v2")]
132+
async fn fallback_sender(&self, _session_id: crate::db::v2::SessionId) -> Result<()> {
133+
anyhow::bail!("fallback is only supported for v2 (BIP77) sessions")
134+
}
119135
}
120136

121137
impl App {

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

Lines changed: 74 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -166,9 +166,8 @@ impl AppTrait for App {
166166
match uri.extras.pj_param() {
167167
#[cfg(feature = "v1")]
168168
PjParam::V1(pj_param) => {
169-
use std::str::FromStr;
170-
171169
let psbt = self.create_original_psbt(&address, amount, fee_rate)?;
170+
let fallback_tx = psbt.clone().extract_tx()?;
172171
let (req, ctx) = payjoin::send::v1::SenderBuilder::from_parts(
173172
psbt,
174173
pj_param,
@@ -181,25 +180,36 @@ impl AppTrait for App {
181180
let http = http_agent(&self.config)?;
182181
let body = String::from_utf8(req.body.clone()).unwrap();
183182
println!("Sending Original PSBT to {}", req.url);
184-
let response = http
183+
let response = match http
185184
.post(req.url)
186185
.header("Content-Type", req.content_type)
187186
.body(body.clone())
188187
.send()
189188
.await
190-
.with_context(|| "HTTP request failed")?;
191-
let fallback_tx = payjoin::bitcoin::Psbt::from_str(&body)
192-
.map_err(|e| anyhow!("Failed to load PSBT from base64: {}", e))?
193-
.extract_tx()?;
194-
println!("Fallback transaction txid: {}", fallback_tx.compute_txid());
195-
println!(
196-
"Fallback transaction hex: {:#}",
197-
payjoin::bitcoin::consensus::encode::serialize_hex(&fallback_tx)
198-
);
199-
let psbt = ctx.process_response(&response.bytes().await?).map_err(|e| {
200-
tracing::debug!("Error processing response: {e:?}");
201-
anyhow!("Failed to process response {e}")
202-
})?;
189+
{
190+
Ok(response) => response,
191+
Err(e) => {
192+
tracing::error!("HTTP request failed: {e}");
193+
println!("Payjoin failed. To broadcast the fallback transaction, run:");
194+
println!(
195+
" bitcoin-cli -rpcwallet=<wallet> sendrawtransaction {:#}",
196+
payjoin::bitcoin::consensus::encode::serialize_hex(&fallback_tx)
197+
);
198+
return Err(anyhow!("HTTP request failed: {e}"));
199+
}
200+
};
201+
let psbt = match ctx.process_response(&response.bytes().await?) {
202+
Ok(psbt) => psbt,
203+
Err(e) => {
204+
tracing::error!("Error processing response: {e:?}");
205+
println!("Payjoin failed. To broadcast the fallback transaction, run:");
206+
println!(
207+
" bitcoin-cli -rpcwallet=<wallet> sendrawtransaction {:#}",
208+
payjoin::bitcoin::consensus::encode::serialize_hex(&fallback_tx)
209+
);
210+
return Err(anyhow!("Failed to process response {e}"));
211+
}
212+
};
203213

204214
self.process_pj_response(psbt)?;
205215
Ok(())
@@ -241,9 +251,21 @@ impl AppTrait for App {
241251
};
242252
let mut interrupt = self.interrupt.clone();
243253
tokio::select! {
244-
res = self.process_sender_session(sender_state, &persister) => return res,
254+
res = self.process_sender_session(sender_state, &persister) => {
255+
match res {
256+
Ok(()) => return Ok(()),
257+
Err(err) => {
258+
let id = persister.session_id();
259+
println!("Session {id} failed. Run `payjoin-cli fallback {id}` to broadcast the original transaction.");
260+
return Err(err);
261+
}
262+
}
263+
},
245264
_ = interrupt.changed() => {
246-
println!("Interrupted. Call `send` with the same arguments to resume this session or `resume` to resume all sessions.");
265+
let id = persister.session_id();
266+
println!(
267+
"Session {id} interrupted. Call `send` again to resume, `resume` to resume all sessions, or `payjoin-cli fallback {id}` to broadcast the original transaction."
268+
);
247269
return Err(anyhow!("Interrupted"))
248270
}
249271
}
@@ -461,6 +483,32 @@ impl AppTrait for App {
461483

462484
Ok(())
463485
}
486+
487+
async fn fallback_sender(&self, session_id: SessionId) -> Result<()> {
488+
let persister = SenderPersister::from_id(self.db.clone(), session_id.clone());
489+
let (session, history) = replay_sender_event_log(&persister)?;
490+
491+
if let SendSession::Closed(SenderSessionOutcome::Success(proposal)) = session {
492+
let txid = proposal.clone().extract_tx_unchecked_fee_rate().compute_txid();
493+
println!(
494+
"Session {session_id} already produced payjoin transaction {txid}. \
495+
Broadcasting the original now would double-spend against it. \
496+
If the payjoin tx needs re-broadcast, run \
497+
`bitcoin-cli gettransaction {txid}` to fetch the hex, then \
498+
`bitcoin-cli sendrawtransaction <hex>`."
499+
);
500+
return Ok(());
501+
}
502+
503+
let fallback_tx = history.fallback_tx();
504+
self.wallet().broadcast_tx(&fallback_tx)?;
505+
println!("Broadcasted fallback transaction txid: {}", fallback_tx.compute_txid());
506+
507+
if let Err(e) = SessionPersister::close(&persister) {
508+
tracing::warn!("Failed to close session {session_id} after fallback: {e}");
509+
}
510+
Ok(())
511+
}
464512
}
465513

466514
impl App {
@@ -489,7 +537,14 @@ impl App {
489537
self.process_pj_response(proposal)?;
490538
return Ok(());
491539
}
492-
_ => return Err(anyhow!("Unexpected sender state")),
540+
SendSession::Closed(SenderSessionOutcome::Failure)
541+
| SendSession::Closed(SenderSessionOutcome::Cancel) => {
542+
let id = persister.session_id();
543+
println!(
544+
"Session {id} ended without payjoin. Run `payjoin-cli fallback {id}` to broadcast the original transaction."
545+
);
546+
return Ok(());
547+
}
493548
}
494549
Ok(())
495550
}

payjoin-cli/src/cli/mod.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,13 @@ pub enum Commands {
133133
#[cfg(feature = "v2")]
134134
/// Show payjoin session history
135135
History,
136+
#[cfg(feature = "v2")]
137+
/// Broadcast the original transaction for a sender session (BIP77/v2 only)
138+
Fallback {
139+
/// The session ID to broadcast the fallback transaction for
140+
#[arg(required = true)]
141+
session_id: i64,
142+
},
136143
}
137144

138145
pub fn parse_amount_in_sat(s: &str) -> Result<Amount, ParseAmountError> {

payjoin-cli/src/db/v2.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use rusqlite::params;
99
use super::*;
1010

1111
#[derive(Debug, Clone)]
12-
pub(crate) struct SessionId(i64);
12+
pub(crate) struct SessionId(pub(crate) i64);
1313

1414
impl core::ops::Deref for SessionId {
1515
type Target = i64;
@@ -61,6 +61,8 @@ impl SenderPersister {
6161
}
6262

6363
pub fn from_id(db: Arc<Database>, id: SessionId) -> Self { Self { db, session_id: id } }
64+
65+
pub fn session_id(&self) -> SessionId { self.session_id.clone() }
6466
}
6567
impl SessionPersister for SenderPersister {
6668
type SessionEvent = SenderSessionEvent;

payjoin-cli/src/main.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ use cli::{Cli, Commands};
66
use tracing_subscriber::filter::LevelFilter;
77
use tracing_subscriber::EnvFilter;
88

9+
#[cfg(feature = "v2")]
10+
use crate::db::v2::SessionId;
11+
912
mod app;
1013
mod cli;
1114
mod db;
@@ -75,6 +78,10 @@ async fn main() -> Result<()> {
7578
Commands::History => {
7679
app.history().await?;
7780
}
81+
#[cfg(feature = "v2")]
82+
Commands::Fallback { session_id } => {
83+
app.fallback_sender(SessionId(*session_id)).await?;
84+
}
7885
};
7986

8087
Ok(())

0 commit comments

Comments
 (0)