• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

payjoin / rust-payjoin / 29031142295

09 Jul 2026 03:50PM UTC coverage: 85.932% (-0.07%) from 86.003%
29031142295

Pull #1721

github

web-flow
Merge 92e311bf4 into fb210807b
Pull Request #1721: Make persister no longer treat transient errors as an error

51 of 89 new or added lines in 3 files covered. (57.3%)

2 existing lines in 2 files now uncovered.

13487 of 15695 relevant lines covered (85.93%)

347.63 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

51.59
/payjoin-cli/src/app/v2/mod.rs
1
use std::fmt;
2
use std::sync::Arc;
3

4
use anyhow::{anyhow, Context, Result};
5
use payjoin::bitcoin::consensus::encode::serialize_hex;
6
use payjoin::bitcoin::{Amount, FeeRate};
7
use payjoin::persist::{OptionalTransitionOutcome, SessionPersister, TerminalSuccessOutcome};
8
use payjoin::receive::v2::{
9
    replay_event_log as replay_receiver_event_log, HasReplyableError, Initialized,
10
    MaybeInputsOwned, MaybeInputsSeen, Monitor, OutputsUnknown, PayjoinProposal,
11
    PendingFallback as ReceiverPendingFallback, ProvisionalProposal, ReceiveSession, Receiver,
12
    ReceiverBuilder, SessionOutcome as ReceiverSessionOutcome, UncheckedOriginalPayload,
13
    WantsFeeRange, WantsInputs, WantsOutputs,
14
};
15
use payjoin::send::v2::{
16
    replay_event_log as replay_sender_event_log, PendingFallback as SenderPendingFallback,
17
    PollingForProposal, SendSession, Sender, SenderBuilder, SessionOutcome as SenderSessionOutcome,
18
    WithReplyKey,
19
};
20
use payjoin::{ImplementationError, PjParam, Uri};
21
use tokio::sync::watch;
22

23
use super::config::Config;
24
use super::wallet::BitcoindWallet;
25
use super::App as AppTrait;
26
use crate::app::v2::ohttp::MailroomManager;
27
use crate::app::{handle_interrupt, http_agent};
28
use crate::cli::Role as CliRole;
29
use crate::db::v2::{ReceiverPersister, SenderPersister, SessionId};
30
use crate::db::Database;
31

32
mod ohttp;
33

34
const W_ID: usize = 36;
35
const W_ROLE: usize = 25;
36
const W_STATUS: usize = 15;
37

38
#[derive(Clone)]
39
pub(crate) struct App {
40
    config: Config,
41
    db: Arc<Database>,
42
    wallet: BitcoindWallet,
43
    interrupt: watch::Receiver<()>,
44
    mailroom_manager: MailroomManager,
45
}
46

47
trait StatusText {
48
    fn status_text(&self) -> &'static str;
49
}
50

51
impl StatusText for SendSession {
52
    fn status_text(&self) -> &'static str {
×
53
        match self {
×
54
            SendSession::WithReplyKey(_) | SendSession::PollingForProposal(_) =>
55
                "Waiting for proposal",
×
56
            SendSession::Closed(session_outcome) => match session_outcome {
×
57
                SenderSessionOutcome::Aborted => "Session aborted",
×
58
                SenderSessionOutcome::Success(_) => "Session success",
×
59
            },
60
            SendSession::PendingFallback(_) => "Session awaiting fallback",
×
61
        }
62
    }
×
63
}
64

65
impl StatusText for ReceiveSession {
66
    fn status_text(&self) -> &'static str {
×
67
        match self {
×
68
            ReceiveSession::Initialized(_) => "Waiting for original proposal",
×
69
            ReceiveSession::UncheckedOriginalPayload(_)
70
            | ReceiveSession::MaybeInputsOwned(_)
71
            | ReceiveSession::MaybeInputsSeen(_)
72
            | ReceiveSession::OutputsUnknown(_)
73
            | ReceiveSession::WantsOutputs(_)
74
            | ReceiveSession::WantsInputs(_)
75
            | ReceiveSession::WantsFeeRange(_)
76
            | ReceiveSession::ProvisionalProposal(_) => "Processing original proposal",
×
77
            ReceiveSession::PayjoinProposal(_) => "Payjoin proposal sent",
×
78
            ReceiveSession::HasReplyableError(_) =>
79
                "Session failure, waiting to post error response",
×
80
            ReceiveSession::Monitor(_) => "Monitoring payjoin proposal",
×
81
            ReceiveSession::PendingFallback(_) => "Pending fallback handling",
×
82
            ReceiveSession::Closed(session_outcome) => match session_outcome {
×
83
                ReceiverSessionOutcome::Aborted => "Session aborted",
×
84
                ReceiverSessionOutcome::Success(_) => "Session success, Payjoin proposal was broadcasted",
×
85
                ReceiverSessionOutcome::FallbackBroadcasted => "Fallback broadcasted",
×
86
                ReceiverSessionOutcome::PayjoinProposalSent =>
87
                    "Payjoin proposal sent, skipping monitoring as the sender is spending non-SegWit inputs",
×
88
            },
89
        }
90
    }
×
91
}
92

93
fn print_header() {
×
94
    println!("{:<W_ID$} {:<W_ROLE$} {:<W_STATUS$}", "Session ID", "Sender/Receiver", "Status");
×
95
}
×
96

97
enum Role {
98
    Sender,
99
    Receiver,
100
}
101
impl Role {
102
    fn as_str(&self) -> &'static str {
×
103
        match self {
×
104
            Role::Sender => "Sender",
×
105
            Role::Receiver => "Receiver",
×
106
        }
107
    }
×
108
}
109

110
struct SessionHistoryRow<Status> {
111
    session_id: SessionId,
112
    role: Role,
113
    status: Status,
114
    error_message: Option<String>,
115
}
116

117
impl<Status: StatusText> fmt::Display for SessionHistoryRow<Status> {
118
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
×
119
        write!(
×
120
            f,
×
121
            "{:<W_ID$} {:<W_ROLE$} {:<W_STATUS$}",
122
            self.session_id.to_string(),
×
123
            self.role.as_str(),
×
124
            self.error_message.as_deref().unwrap_or(self.status.status_text())
×
125
        )
126
    }
×
127
}
128

129
#[async_trait::async_trait]
130
impl AppTrait for App {
131
    async fn new(config: Config) -> Result<Self> {
16✔
132
        let db = Arc::new(Database::create(&config.db_path)?);
133
        let mailroom_manager = MailroomManager::new(config.clone());
134
        let (interrupt_tx, interrupt_rx) = watch::channel(());
135
        tokio::spawn(handle_interrupt(interrupt_tx));
136
        let wallet = BitcoindWallet::new(&config.bitcoind).await?;
137
        let app = Self { config, db, wallet, interrupt: interrupt_rx, mailroom_manager };
138
        app.wallet()
139
            .network()
140
            .context("Failed to connect to bitcoind. Check config RPC connection.")?;
141
        Ok(app)
142
    }
16✔
143

144
    fn wallet(&self) -> BitcoindWallet { self.wallet.clone() }
86✔
145

146
    #[allow(clippy::incompatible_msrv)]
147
    async fn send_payjoin(&self, bip21: &str, fee_rate: FeeRate) -> Result<()> {
4✔
148
        use payjoin::UriExt;
149
        let uri = Uri::try_from(bip21)
150
            .map_err(|e| anyhow!("Failed to create URI from BIP21: {}", e))?
×
151
            .assume_checked()
152
            .check_pj_supported()
153
            .map_err(|_| anyhow!("URI does not support Payjoin"))?;
×
154
        let address = uri.address;
155
        let amount = uri.amount.ok_or_else(|| anyhow!("please specify the amount in the Uri"))?;
×
156
        match uri.extras.pj_param() {
157
            #[cfg(feature = "v1")]
158
            PjParam::V1(pj_param) => {
159
                let psbt = self.create_original_psbt(&address, amount, fee_rate)?;
160
                let fallback_tx = psbt.clone().extract_tx()?;
161
                let (req, ctx) = payjoin::send::v1::SenderBuilder::from_parts(
162
                    psbt,
163
                    pj_param,
164
                    &address,
165
                    Some(amount),
166
                )
167
                .build_recommended(fee_rate)
168
                .with_context(|| "Failed to build payjoin request")?
169
                .create_v1_post_request();
170
                let http = http_agent(&self.config)?;
171
                let body = String::from_utf8(req.body.clone()).unwrap();
172
                println!("Sending Original PSBT to {}", req.url);
173
                let response = match http
174
                    .post(req.url)
175
                    .header("Content-Type", req.content_type)
176
                    .body(body.clone())
177
                    .send()
178
                    .await
179
                {
180
                    Ok(response) => response,
181
                    Err(e) => {
182
                        tracing::error!("HTTP request failed: {e}");
183
                        println!("Payjoin failed. To broadcast the fallback transaction, run:");
184
                        println!(
185
                            "  bitcoin-cli -rpcwallet=<wallet> sendrawtransaction {:#}",
186
                            payjoin::bitcoin::consensus::encode::serialize_hex(&fallback_tx)
187
                        );
188
                        return Err(anyhow!("HTTP request failed: {e}"));
189
                    }
190
                };
191
                let psbt = match ctx.process_response(&response.bytes().await?) {
192
                    Ok(psbt) => psbt,
193
                    Err(e) => {
194
                        tracing::error!("Error processing response: {e:?}");
195
                        println!("Payjoin failed. To broadcast the fallback transaction, run:");
196
                        println!(
197
                            "  bitcoin-cli -rpcwallet=<wallet> sendrawtransaction {:#}",
198
                            payjoin::bitcoin::consensus::encode::serialize_hex(&fallback_tx)
199
                        );
200
                        return Err(anyhow!("Failed to process response {e}"));
201
                    }
202
                };
203

204
                self.process_pj_response(psbt)?;
205
                Ok(())
206
            }
207
            PjParam::V2(pj_param) => {
208
                let receiver_pubkey = pj_param.receiver_pubkey();
209
                let sender_state = self
210
                    .db
211
                    .get_send_session_id_by_receiver_pk(receiver_pubkey)?
212
                    .and_then(|session_id| {
1✔
213
                        let sender_persister =
1✔
214
                            SenderPersister::from_id(self.db.clone(), session_id);
1✔
215
                        let (send_session, _) = replay_sender_event_log(&sender_persister)
1✔
216
                            .map_err(|e| anyhow!("Failed to replay sender event log: {:?}", e))
1✔
217
                            .ok()?;
1✔
218
                        Some((send_session, sender_persister))
1✔
219
                    });
1✔
220

221
                let (sender_state, persister) = match sender_state {
222
                    Some((sender_state, persister)) => (sender_state, persister),
223
                    None => {
224
                        let psbt = self.create_original_psbt(&address, amount, fee_rate)?;
225
                        let persister =
226
                            SenderPersister::new(self.db.clone(), bip21, receiver_pubkey)?;
227
                        let sender =
228
                            SenderBuilder::from_parts(psbt, pj_param, &address, Some(amount))
229
                                .build_recommended(fee_rate)?
230
                                .save(&persister)?;
231

232
                        (SendSession::WithReplyKey(sender), persister)
233
                    }
234
                };
235
                println!("Send session established: {}", persister.session_id());
236
                let mut interrupt = self.interrupt.clone();
237
                tokio::select! {
238
                    res = self.process_sender_session(sender_state, &persister) => {
239
                        match res {
240
                            Ok(()) => return Ok(()),
241
                            Err(err) => {
242
                                let id = persister.session_id();
243
                                println!("Session {id} failed. Run `payjoin-cli cancel {id}` to cancel and broadcast the original transaction.");
244
                                return Err(err);
245
                            }
246
                        }
247
                    },
248
                    _ = interrupt.changed() => {
249
                        let id = persister.session_id();
250
                        println!(
251
                            "Session {id} interrupted. Call `send` again to resume, `resume` to resume all sessions, or `payjoin-cli cancel {id}` to cancel and broadcast the original transaction."
252
                        );
253
                        return Err(anyhow!("Interrupted"))
254
                    }
255
                }
256
            }
257
            _ => unimplemented!("Unrecognized payjoin version"),
258
        }
259
    }
4✔
260

261
    async fn receive_payjoin(&self, amount: Amount) -> Result<()> {
3✔
262
        let address = self.wallet().get_new_address()?;
263
        let persister = ReceiverPersister::new(self.db.clone())?;
264
        let (directory, ohttp_keys) = loop {
265
            let directory = self.mailroom_manager.choose_directory()?;
266
            match self
267
                .mailroom_manager
268
                .unwrap_ohttp_keys_or_else_fetch_from_directory(&directory)
269
                .await
270
            {
271
                Ok(keys) => break (directory, keys.ohttp_keys),
272
                Err(e) => {
273
                    tracing::debug!("Directory {directory} failed: {e:#}");
274
                    self.mailroom_manager.add_failed_directory(directory);
275
                    self.mailroom_manager.clear_failed_relays();
276
                    continue;
277
                }
278
            }
279
        };
280
        let mut receiver_builder =
281
            ReceiverBuilder::new(address, directory.as_str(), ohttp_keys)?.with_amount(amount);
282
        if let Some(max_fee_rate) = self.config.max_fee_rate {
283
            receiver_builder = receiver_builder.with_max_fee_rate(max_fee_rate);
284
        }
285
        let session = receiver_builder.build().save(&persister)?;
286

287
        println!("Receive session established: {}", persister.session_id());
288
        let pj_uri = session.pj_uri();
289
        println!("Request Payjoin by sharing this Payjoin Uri:");
290
        println!("{pj_uri}");
291

292
        self.process_receiver_session(ReceiveSession::Initialized(session.clone()), &persister)
293
            .await?;
294
        Ok(())
295
    }
3✔
296

297
    async fn resume_payjoins(&self, session_id: Option<SessionId>) -> Result<()> {
5✔
298
        let mut recv_session_ids = self.db.get_recv_session_ids()?;
299
        let mut send_session_ids = self.db.get_send_session_ids()?;
300

301
        if let Some(ref target_id) = session_id {
302
            recv_session_ids.retain(|id| id == target_id);
×
303
            send_session_ids.retain(|id| id == target_id);
×
304
            if recv_session_ids.is_empty() && send_session_ids.is_empty() {
305
                anyhow::bail!("Session {target_id} not found or already completed");
306
            }
307
        }
308

309
        if recv_session_ids.is_empty() && send_session_ids.is_empty() {
310
            println!("No sessions to resume.");
311
            return Ok(());
312
        }
313

314
        let mut tasks: Vec<(String, tokio::task::JoinHandle<Result<()>>)> = Vec::new();
315

316
        // Process receiver sessions
317
        for session_id in recv_session_ids {
318
            let self_clone = self.clone();
319
            let recv_persister = ReceiverPersister::from_id(self.db.clone(), session_id.clone());
320
            match replay_receiver_event_log(&recv_persister) {
321
                Ok((receiver_state, _)) => {
322
                    tasks.push((
323
                        session_id.to_string(),
324
                        tokio::spawn(async move {
3✔
325
                            self_clone
3✔
326
                                .process_receiver_session(receiver_state, &recv_persister)
3✔
327
                                .await
3✔
328
                        }),
2✔
329
                    ));
330
                }
331
                Err(e) => {
332
                    if e.is_expired() {
333
                        println!("Session {session_id} receiver expired.");
334
                    } else {
335
                        tracing::error!(
336
                            "An error {:?} occurred while replaying receiver session",
337
                            e
338
                        );
339
                        println!("Session {session_id} receiver failed to replay -  {e}");
340
                    }
341
                    Self::close_failed_session(&recv_persister, &session_id, "receiver");
342
                }
343
            }
344
        }
345

346
        // Process sender sessions
347
        for session_id in send_session_ids {
348
            let sender_persister = SenderPersister::from_id(self.db.clone(), session_id.clone());
349
            match replay_sender_event_log(&sender_persister) {
350
                Ok((sender_state, _)) => {
351
                    let self_clone = self.clone();
352
                    tasks.push((
353
                        session_id.clone().to_string(),
354
                        tokio::spawn(async move {
×
355
                            self_clone.process_sender_session(sender_state, &sender_persister).await
×
356
                        }),
×
357
                    ));
358
                }
359
                Err(e) => {
360
                    if e.is_expired() {
361
                        println!("Session {session_id} sender expired.");
362
                    } else {
363
                        tracing::error!("An error {:?} occurred while replaying Sender session", e);
364
                        println!("Session {session_id} sender failed to replay -  {e}");
365
                    }
366
                    Self::close_failed_session(&sender_persister, &session_id, "sender");
367
                }
368
            }
369
        }
370

371
        let mut interrupt = self.interrupt.clone();
372
        tokio::select! {
373
            _ = async {
3✔
374

375
                for (session_id, task) in tasks {
3✔
376
                    match task.await {
3✔
377
                        Ok(Ok(())) => {
1✔
378
                            println!("Session {session_id} completed.");
1✔
379
                        }
1✔
380
                        Ok(Err(e)) => {
1✔
381
                            println!("Session {session_id} error: {e:#}");
1✔
382
                        }
1✔
383
                        Err(e) => {
×
384
                            println!("Session {session_id} panicked or was cancelled: {e:?}");
×
385
                        }
×
386
                    }
387
                }
388
        } => {}
2✔
389
            _ = interrupt.changed() => {
390
                println!("Resumed sessions were interrupted.");
391
            }
392
        }
393
        Ok(())
394
    }
5✔
395

396
    #[cfg(feature = "v2")]
397
    async fn history(&self) -> Result<()> {
×
398
        print_header();
399
        let mut send_rows = vec![];
400
        let mut recv_rows = vec![];
401
        self.db.get_send_session_ids()?.into_iter().for_each(|session_id| {
×
402
            let persister = SenderPersister::from_id(self.db.clone(), session_id.clone());
×
403
            match replay_sender_event_log(&persister) {
×
404
                Ok((sender_state, _)) => {
×
405
                    let row = SessionHistoryRow {
×
406
                        session_id,
×
407
                        role: Role::Sender,
×
408
                        status: sender_state.clone(),
×
409
                        error_message: None,
×
410
                    };
×
411
                    send_rows.push(row);
×
412
                }
×
413
                Err(e) => {
×
414
                    let row = SessionHistoryRow {
×
415
                        session_id,
×
416
                        role: Role::Sender,
×
417
                        status: SendSession::Closed(SenderSessionOutcome::Aborted),
×
418
                        error_message: Some(e.to_string()),
×
419
                    };
×
420
                    send_rows.push(row);
×
421
                }
×
422
            }
423
        });
×
424

425
        self.db.get_recv_session_ids()?.into_iter().for_each(|session_id| {
×
426
            let persister = ReceiverPersister::from_id(self.db.clone(), session_id.clone());
×
427
            match replay_receiver_event_log(&persister) {
×
428
                Ok((receiver_state, _)) => {
×
429
                    let row = SessionHistoryRow {
×
430
                        session_id,
×
431
                        role: Role::Receiver,
×
432
                        status: receiver_state.clone(),
×
433
                        error_message: None,
×
434
                    };
×
435
                    recv_rows.push(row);
×
436
                }
×
437
                Err(e) => {
×
438
                    let row = SessionHistoryRow {
×
439
                        session_id,
×
440
                        role: Role::Receiver,
×
441
                        status: ReceiveSession::Closed(ReceiverSessionOutcome::Aborted),
×
442
                        error_message: Some(e.to_string()),
×
443
                    };
×
444
                    recv_rows.push(row);
×
445
                }
×
446
            }
447
        });
×
448

449
        self.db.get_inactive_send_session_ids()?.into_iter().for_each(|(session_id, _)| {
×
450
            let persister = SenderPersister::from_id(self.db.clone(), session_id.clone());
×
451
            match replay_sender_event_log(&persister) {
×
452
                Ok((sender_state, _)) => {
×
453
                    let row = SessionHistoryRow {
×
454
                        session_id,
×
455
                        role: Role::Sender,
×
456
                        status: sender_state.clone(),
×
457
                        error_message: None,
×
458
                    };
×
459
                    send_rows.push(row);
×
460
                }
×
461
                Err(e) => {
×
462
                    let row = SessionHistoryRow {
×
463
                        session_id,
×
464
                        role: Role::Sender,
×
465
                        status: SendSession::Closed(SenderSessionOutcome::Aborted),
×
466
                        error_message: Some(e.to_string()),
×
467
                    };
×
468
                    send_rows.push(row);
×
469
                }
×
470
            }
471
        });
×
472

473
        self.db.get_inactive_recv_session_ids()?.into_iter().for_each(|(session_id, _)| {
×
474
            let persister = ReceiverPersister::from_id(self.db.clone(), session_id.clone());
×
475
            match replay_receiver_event_log(&persister) {
×
476
                Ok((receiver_state, _)) => {
×
477
                    let row = SessionHistoryRow {
×
478
                        session_id,
×
479
                        role: Role::Receiver,
×
480
                        status: receiver_state.clone(),
×
481
                        error_message: None,
×
482
                    };
×
483
                    recv_rows.push(row);
×
484
                }
×
485
                Err(e) => {
×
486
                    let row = SessionHistoryRow {
×
487
                        session_id,
×
488
                        role: Role::Receiver,
×
489
                        status: ReceiveSession::Closed(ReceiverSessionOutcome::Aborted),
×
490
                        error_message: Some(e.to_string()),
×
491
                    };
×
492
                    recv_rows.push(row);
×
493
                }
×
494
            }
495
        });
×
496

497
        // Collect every row (active + inactive, sender + receiver), sort by
498
        // session id, and print in a single stable order regardless of which
499
        // table or active/inactive bucket the row came from.
500
        let mut rows: Vec<(SessionId, String)> = Vec::new();
501
        for row in send_rows {
502
            rows.push((row.session_id.clone(), format!("{row}")));
503
        }
504
        for row in recv_rows {
505
            rows.push((row.session_id.clone(), format!("{row}")));
506
        }
507
        rows.sort_by_key(|(id, _)| id.clone());
×
508
        for (_, line) in rows {
509
            println!("{line}");
510
        }
511

512
        Ok(())
513
    }
×
514

515
    async fn cancel(
516
        &self,
517
        session_id: SessionId,
518
        no_broadcast: bool,
519
        role: Option<CliRole>,
520
    ) -> Result<()> {
4✔
521
        if let Some(role) = role {
522
            return match role {
523
                CliRole::Sender => self.cancel_sender_session(session_id, no_broadcast),
524
                CliRole::Receiver => self.cancel_receiver_session(session_id, no_broadcast),
525
            };
526
        }
527

528
        let is_sender = self.db.send_session_exists(&session_id)?;
529
        let is_receiver = self.db.recv_session_exists(&session_id)?;
530

531
        match (is_sender, is_receiver) {
532
            (true, false) => self.cancel_sender_session(session_id, no_broadcast),
533
            (false, true) => self.cancel_receiver_session(session_id, no_broadcast),
534
            (true, true) => anyhow::bail!(
535
                "Session {session_id} exists as both a sender and receiver session. \
536
                 Pass `--role sender` or `--role receiver`."
537
            ),
538
            (false, false) => anyhow::bail!("Session {session_id} not found"),
539
        }
540
    }
4✔
541
}
542

543
impl App {
544
    fn cancel_sender_session(&self, session_id: SessionId, no_broadcast: bool) -> Result<()> {
2✔
545
        let persister = SenderPersister::from_id(self.db.clone(), session_id.clone());
2✔
546
        let (session, history) = replay_sender_event_log(&persister)?;
2✔
547

548
        let pending: Sender<SenderPendingFallback> = match session {
1✔
549
            SendSession::WithReplyKey(sender) => sender.cancel().save(&persister)?,
1✔
550
            SendSession::PollingForProposal(sender) => sender.cancel().save(&persister)?,
×
551
            SendSession::PendingFallback(sender) => sender,
×
552
            SendSession::Closed(SenderSessionOutcome::Success(proposal)) => {
×
553
                let txid = proposal.extract_tx_unchecked_fee_rate().compute_txid();
×
554
                println!(
×
555
                    "Session {session_id} already produced payjoin transaction {txid}. \
556
                     Cannot cancel a completed session."
557
                );
558
                return Ok(());
×
559
            }
560
            SendSession::Closed(SenderSessionOutcome::Aborted) => {
561
                println!(
1✔
562
                    "Session {session_id} was already cancelled. Broadcast the original transaction manually:\n{}",
563
                    serialize_hex(&history.fallback_tx())
1✔
564
                );
565
                return Ok(());
1✔
566
            }
567
        };
568

569
        if no_broadcast {
1✔
570
            println!(
×
571
                "Session {session_id} cancelled. Broadcast the original transaction manually:\n{}",
×
572
                serialize_hex(pending.fallback_tx())
×
573
            );
×
574
        } else {
×
575
            self.wallet().broadcast_tx(pending.fallback_tx())?;
1✔
576
            println!(
1✔
577
                "Broadcasted fallback transaction txid: {}",
578
                pending.fallback_tx().compute_txid()
1✔
579
            );
580
        }
581
        pending.close().save(&persister)?;
1✔
582
        Ok(())
1✔
583
    }
2✔
584

585
    fn cancel_receiver_session(&self, session_id: SessionId, no_broadcast: bool) -> Result<()> {
2✔
586
        let persister = ReceiverPersister::from_id(self.db.clone(), session_id.clone());
2✔
587
        let (session, history) = replay_receiver_event_log(&persister)?;
2✔
588

589
        let pending: Receiver<ReceiverPendingFallback> = match session {
1✔
590
            ReceiveSession::Initialized(receiver) => {
1✔
591
                receiver.cancel().save(&persister)?;
1✔
592
                println!("Session {session_id} cancelled. No fallback transaction to broadcast.");
1✔
593
                return Ok(());
1✔
594
            }
595
            ReceiveSession::UncheckedOriginalPayload(receiver) => {
×
596
                receiver.cancel().save(&persister)?;
×
597
                println!("Session {session_id} cancelled. No fallback transaction to broadcast.");
×
598
                return Ok(());
×
599
            }
600
            ReceiveSession::MaybeInputsOwned(receiver) => receiver.cancel().save(&persister)?,
×
601
            ReceiveSession::MaybeInputsSeen(receiver) => receiver.cancel().save(&persister)?,
×
602
            ReceiveSession::OutputsUnknown(receiver) => receiver.cancel().save(&persister)?,
×
603
            ReceiveSession::WantsOutputs(receiver) => receiver.cancel().save(&persister)?,
×
604
            ReceiveSession::WantsInputs(receiver) => receiver.cancel().save(&persister)?,
×
605
            ReceiveSession::WantsFeeRange(receiver) => receiver.cancel().save(&persister)?,
×
606
            ReceiveSession::ProvisionalProposal(receiver) => receiver.cancel().save(&persister)?,
×
607
            ReceiveSession::PayjoinProposal(receiver) => receiver.cancel().save(&persister)?,
×
608
            ReceiveSession::Monitor(receiver) => receiver.cancel().save(&persister)?,
×
609
            ReceiveSession::HasReplyableError(receiver) => match receiver
×
610
                .cancel()
×
611
                .save(&persister)?
×
612
            {
613
                Some(pending) => pending,
×
614
                None => {
615
                    println!("Session {session_id} cancelled. No fallback transaction available.");
×
616
                    return Ok(());
×
617
                }
618
            },
619
            ReceiveSession::PendingFallback(receiver) => receiver,
×
620
            ReceiveSession::Closed(
621
                ReceiverSessionOutcome::Success(_)
622
                | ReceiverSessionOutcome::FallbackBroadcasted
623
                | ReceiverSessionOutcome::PayjoinProposalSent,
624
            ) => {
625
                println!("Session {session_id} already completed successfully. Cannot cancel.");
×
626
                return Ok(());
×
627
            }
628
            ReceiveSession::Closed(ReceiverSessionOutcome::Aborted) => {
629
                match history.fallback_tx() {
1✔
630
                    Some(tx) => println!(
×
631
                        "Session {session_id} was already cancelled. Broadcast the fallback transaction manually:\n{}",
632
                        serialize_hex(&tx)
×
633
                    ),
634
                    None => println!(
1✔
635
                        "Session {session_id} is already closed. No fallback transaction available."
636
                    ),
637
                }
638
                return Ok(());
1✔
639
            }
640
        };
641

642
        if no_broadcast {
×
643
            println!(
×
644
                "Session {session_id} cancelled. Broadcast the fallback transaction manually:\n{}",
×
645
                serialize_hex(pending.fallback_tx())
×
646
            );
×
647
        } else {
×
648
            self.wallet().broadcast_tx(pending.fallback_tx())?;
×
649
            println!(
×
650
                "Broadcasted fallback transaction txid: {}",
651
                pending.fallback_tx().compute_txid()
×
652
            );
653
        }
654
        pending.close().save(&persister)?;
×
655
        Ok(())
×
656
    }
2✔
657

658
    fn close_failed_session<P>(persister: &P, session_id: &SessionId, role: &str)
×
659
    where
×
660
        P: SessionPersister,
×
661
    {
662
        if let Err(close_err) = SessionPersister::close(persister) {
×
663
            tracing::error!("Failed to close {} session {}: {:?}", role, session_id, close_err);
×
664
        } else {
665
            tracing::error!("Closed failed {} session: {}", role, session_id);
×
666
        }
667
    }
×
668

669
    async fn process_sender_session(
3✔
670
        &self,
3✔
671
        session: SendSession,
3✔
672
        persister: &SenderPersister,
3✔
673
    ) -> Result<()> {
3✔
674
        match session {
×
675
            SendSession::WithReplyKey(context) =>
2✔
676
                self.post_original_proposal(context, persister).await?,
2✔
677
            SendSession::PollingForProposal(context) =>
1✔
678
                self.get_proposed_payjoin_psbt(context, persister).await?,
1✔
679
            SendSession::Closed(SenderSessionOutcome::Success(proposal)) => {
×
680
                self.process_pj_response(proposal)?;
×
681
                return Ok(());
×
682
            }
683
            SendSession::Closed(SenderSessionOutcome::Aborted) => {
684
                println!("Session is closed. Nothing left to do");
×
685
                return Ok(());
×
686
            }
687
            SendSession::PendingFallback(_) => {
688
                let id = persister.session_id();
×
689
                println!(
×
690
                    "Session {id} was cancelled. Run `payjoin-cli cancel {id}` to cancel and broadcast the fallback transaction."
691
                );
692
                return Ok(());
×
693
            }
694
        }
695
        Ok(())
1✔
696
    }
1✔
697

698
    async fn post_original_proposal(
2✔
699
        &self,
2✔
700
        sender: Sender<WithReplyKey>,
2✔
701
        persister: &SenderPersister,
2✔
702
    ) -> Result<()> {
2✔
703
        let (response, ctx) =
1✔
704
            self.post_via_relay(|relay| sender.create_v2_post_request(relay)).await?;
2✔
705
        let sender = sender.process_response(&response.bytes().await?, ctx).save(persister)?;
1✔
706
        println!("Posted Original PSBT...");
1✔
707
        self.get_proposed_payjoin_psbt(sender, persister).await
1✔
708
    }
×
709

710
    async fn get_proposed_payjoin_psbt(
2✔
711
        &self,
2✔
712
        sender: Sender<PollingForProposal>,
2✔
713
        persister: &SenderPersister,
2✔
714
    ) -> Result<()> {
2✔
715
        let mut session = sender.clone();
2✔
716
        // Long poll until we get a response
717
        loop {
718
            let (response, ctx) =
2✔
719
                self.post_via_relay(|relay| session.create_poll_request(relay)).await?;
3✔
720
            let res = session.process_response(&response.bytes().await?, ctx).save(persister);
2✔
721
            match res {
2✔
722
                Ok(OptionalTransitionOutcome::Progress(psbt)) => {
1✔
723
                    println!("Proposal received. Processing...");
1✔
724
                    self.process_pj_response(psbt)?;
1✔
725
                    return Ok(());
1✔
726
                }
727
                Ok(OptionalTransitionOutcome::Stasis(current_state)) => {
1✔
728
                    println!("No response yet.");
1✔
729
                    session = current_state;
1✔
730
                    continue;
1✔
731
                }
732
                Err(re) => {
×
733
                    println!("{re}");
×
734
                    tracing::debug!("{re:?}");
×
735
                    return Err(anyhow!("Response error").context(re));
×
736
                }
737
            }
738
        }
739
    }
1✔
740

741
    async fn long_poll_fallback(
4✔
742
        &self,
4✔
743
        session: Receiver<Initialized>,
4✔
744
        persister: &ReceiverPersister,
4✔
745
    ) -> Result<Receiver<UncheckedOriginalPayload>> {
4✔
746
        let mut session = session;
4✔
747
        loop {
748
            println!("Polling receive request...");
4✔
749
            let (ohttp_response, context) =
1✔
750
                self.post_via_relay(|relay| session.create_poll_request(relay)).await?;
4✔
751
            let state_transition = session
1✔
752
                .process_response(ohttp_response.bytes().await?.to_vec().as_slice(), context)
1✔
753
                .save(persister);
1✔
754
            match state_transition {
1✔
755
                Ok(OptionalTransitionOutcome::Progress(next_state)) => {
1✔
756
                    println!("Got a request from the sender. Responding with a Payjoin proposal.");
1✔
757
                    return Ok(next_state);
1✔
758
                }
759
                Ok(OptionalTransitionOutcome::Stasis(current_state)) => {
×
760
                    session = current_state;
×
761
                    continue;
×
762
                }
763
                Err(e) => return Err(e.into()),
×
764
            }
765
        }
766
    }
1✔
767

768
    async fn process_receiver_session(
6✔
769
        &self,
6✔
770
        session: ReceiveSession,
6✔
771
        persister: &ReceiverPersister,
6✔
772
    ) -> Result<()> {
6✔
773
        let res = {
5✔
774
            match session {
6✔
775
                ReceiveSession::Initialized(proposal) =>
4✔
776
                    self.read_from_directory(proposal, persister).await,
4✔
777
                ReceiveSession::UncheckedOriginalPayload(proposal) =>
×
778
                    self.check_proposal(proposal, persister).await,
×
779
                ReceiveSession::MaybeInputsOwned(proposal) =>
×
780
                    self.check_inputs_not_owned(proposal, persister).await,
×
781
                ReceiveSession::MaybeInputsSeen(proposal) =>
×
782
                    self.check_no_inputs_seen_before(proposal, persister).await,
×
783
                ReceiveSession::OutputsUnknown(proposal) =>
×
784
                    self.identify_receiver_outputs(proposal, persister).await,
×
785
                ReceiveSession::WantsOutputs(proposal) =>
×
786
                    self.commit_outputs(proposal, persister).await,
×
787
                ReceiveSession::WantsInputs(proposal) =>
×
788
                    self.contribute_inputs(proposal, persister).await,
×
789
                ReceiveSession::WantsFeeRange(proposal) =>
×
790
                    self.apply_fee_range(proposal, persister).await,
×
791
                ReceiveSession::ProvisionalProposal(proposal) =>
×
792
                    self.finalize_proposal(proposal, persister).await,
×
793
                ReceiveSession::PayjoinProposal(proposal) =>
×
794
                    self.send_payjoin_proposal(proposal, persister).await,
×
795
                ReceiveSession::HasReplyableError(error) =>
×
796
                    self.handle_error(error, persister).await,
×
797
                ReceiveSession::Monitor(proposal) =>
2✔
798
                    self.monitor_payjoin_proposal(proposal, persister).await,
2✔
799
                ReceiveSession::PendingFallback(_) => {
800
                    let id = persister.session_id();
×
801
                    println!(
×
802
                        "Session {id} was cancelled. Run `payjoin-cli cancel {id}` to cancel and broadcast the fallback transaction."
803
                    );
804
                    return Ok(());
×
805
                }
806
                ReceiveSession::Closed(_) => return Err(anyhow!("Session closed")),
×
807
            }
808
        };
809
        res
5✔
810
    }
5✔
811

812
    #[allow(clippy::incompatible_msrv)]
813
    async fn read_from_directory(
4✔
814
        &self,
4✔
815
        session: Receiver<Initialized>,
4✔
816
        persister: &ReceiverPersister,
4✔
817
    ) -> Result<()> {
4✔
818
        let mut interrupt = self.interrupt.clone();
4✔
819
        let receiver = tokio::select! {
4✔
820
            res = self.long_poll_fallback(session, persister) => res,
4✔
821
            _ = interrupt.changed() => {
4✔
822
                println!("Interrupted. Call the `resume` command to resume all sessions.");
3✔
823
                return Err(anyhow!("Interrupted"));
3✔
824
            }
825
        }?;
×
826
        self.check_proposal(receiver, persister).await
1✔
827
    }
3✔
828

829
    async fn check_proposal(
1✔
830
        &self,
1✔
831
        proposal: Receiver<UncheckedOriginalPayload>,
1✔
832
        persister: &ReceiverPersister,
1✔
833
    ) -> Result<()> {
1✔
834
        let wallet = self.wallet();
1✔
835
        let proposal = proposal
1✔
836
            .check_broadcast_suitability(None, |tx| {
1✔
837
                wallet
1✔
838
                    .can_broadcast(tx)
1✔
839
                    .map_err(|e| ImplementationError::from(e.into_boxed_dyn_error()))
1✔
840
            })
1✔
841
            .save(persister)?;
1✔
842

843
        println!("Fallback transaction received. Consider broadcasting this to get paid if the Payjoin fails:");
1✔
844
        println!("{}", serialize_hex(&proposal.extract_tx_to_schedule_broadcast()));
1✔
845
        self.check_inputs_not_owned(proposal, persister).await
1✔
846
    }
×
847

848
    async fn check_inputs_not_owned(
1✔
849
        &self,
1✔
850
        proposal: Receiver<MaybeInputsOwned>,
1✔
851
        persister: &ReceiverPersister,
1✔
852
    ) -> Result<()> {
1✔
853
        let wallet = self.wallet();
1✔
854
        let proposal = proposal
1✔
855
            .check_inputs_not_owned(&mut |input| {
1✔
856
                wallet
1✔
857
                    .is_mine(input)
1✔
858
                    .map_err(|e| ImplementationError::from(e.into_boxed_dyn_error()))
1✔
859
            })
1✔
860
            .save(persister)?;
1✔
861
        self.check_no_inputs_seen_before(proposal, persister).await
1✔
862
    }
×
863

864
    async fn check_no_inputs_seen_before(
1✔
865
        &self,
1✔
866
        proposal: Receiver<MaybeInputsSeen>,
1✔
867
        persister: &ReceiverPersister,
1✔
868
    ) -> Result<()> {
1✔
869
        let proposal = proposal
1✔
870
            .check_no_inputs_seen_before(&mut |input| {
1✔
871
                Ok(self.db.insert_input_seen_before(*input)?)
1✔
872
            })
1✔
873
            .save(persister)?;
1✔
874
        self.identify_receiver_outputs(proposal, persister).await
1✔
875
    }
×
876

877
    async fn identify_receiver_outputs(
1✔
878
        &self,
1✔
879
        proposal: Receiver<OutputsUnknown>,
1✔
880
        persister: &ReceiverPersister,
1✔
881
    ) -> Result<()> {
1✔
882
        let wallet = self.wallet();
1✔
883
        let proposal = proposal
1✔
884
            .identify_receiver_outputs(&mut |output_script| {
2✔
885
                wallet
2✔
886
                    .is_mine(output_script)
2✔
887
                    .map_err(|e| ImplementationError::from(e.into_boxed_dyn_error()))
2✔
888
            })
2✔
889
            .save(persister)?;
1✔
890
        self.commit_outputs(proposal, persister).await
1✔
891
    }
×
892

893
    async fn commit_outputs(
1✔
894
        &self,
1✔
895
        proposal: Receiver<WantsOutputs>,
1✔
896
        persister: &ReceiverPersister,
1✔
897
    ) -> Result<()> {
1✔
898
        let proposal = proposal.commit_outputs().save(persister)?;
1✔
899
        self.contribute_inputs(proposal, persister).await
1✔
900
    }
×
901

902
    async fn contribute_inputs(
1✔
903
        &self,
1✔
904
        proposal: Receiver<WantsInputs>,
1✔
905
        persister: &ReceiverPersister,
1✔
906
    ) -> Result<()> {
1✔
907
        let wallet = self.wallet();
1✔
908
        let candidate_inputs = wallet.list_unspent()?;
1✔
909

910
        if candidate_inputs.is_empty() {
1✔
911
            return Err(anyhow::anyhow!(
×
912
                "No spendable UTXOs available in wallet. Cannot contribute inputs to payjoin."
×
913
            ));
×
914
        }
1✔
915

916
        let selected_input = proposal.try_preserving_privacy(candidate_inputs)?;
1✔
917
        let proposal =
1✔
918
            proposal.contribute_inputs(vec![selected_input])?.commit_inputs().save(persister)?;
1✔
919
        self.apply_fee_range(proposal, persister).await
1✔
920
    }
×
921

922
    async fn apply_fee_range(
1✔
923
        &self,
1✔
924
        proposal: Receiver<WantsFeeRange>,
1✔
925
        persister: &ReceiverPersister,
1✔
926
    ) -> Result<()> {
1✔
927
        let proposal = proposal.apply_fee_range(None, self.config.max_fee_rate).save(persister)?;
1✔
928
        self.finalize_proposal(proposal, persister).await
1✔
929
    }
×
930

931
    async fn finalize_proposal(
1✔
932
        &self,
1✔
933
        proposal: Receiver<ProvisionalProposal>,
1✔
934
        persister: &ReceiverPersister,
1✔
935
    ) -> Result<()> {
1✔
936
        let wallet = self.wallet();
1✔
937
        let proposal = proposal
1✔
938
            .finalize_proposal(|psbt| {
1✔
939
                wallet
1✔
940
                    .process_psbt(psbt)
1✔
941
                    .map_err(|e| ImplementationError::from(e.into_boxed_dyn_error()))
1✔
942
            })
1✔
943
            .save(persister)?;
1✔
944
        self.send_payjoin_proposal(proposal, persister).await
1✔
945
    }
×
946

947
    async fn send_payjoin_proposal(
1✔
948
        &self,
1✔
949
        proposal: Receiver<PayjoinProposal>,
1✔
950
        persister: &ReceiverPersister,
1✔
951
    ) -> Result<()> {
1✔
952
        let (res, ohttp_ctx) = self
1✔
953
            .post_via_relay(|relay| {
1✔
954
                proposal
1✔
955
                    .create_post_request(relay)
1✔
956
                    .map_err(|e| anyhow!("v2 req extraction failed {}", e))
1✔
957
            })
1✔
958
            .await?;
1✔
959
        let payjoin_psbt = proposal.psbt().clone();
1✔
960
        let session = proposal.process_response(&res.bytes().await?, ohttp_ctx).save(persister)?;
1✔
961
        println!(
1✔
962
            "Response successful. Watch mempool for successful Payjoin. TXID: {}",
963
            payjoin_psbt.extract_tx_unchecked_fee_rate().compute_txid()
1✔
964
        );
965

966
        return self.monitor_payjoin_proposal(session, persister).await;
1✔
967
    }
×
968

969
    async fn monitor_payjoin_proposal(
3✔
970
        &self,
3✔
971
        proposal: Receiver<Monitor>,
3✔
972
        persister: &ReceiverPersister,
3✔
973
    ) -> Result<()> {
3✔
974
        // On a session resumption, the receiver will resume again in this state.
975
        let poll_interval = tokio::time::Duration::from_millis(200);
3✔
976
        let timeout_duration = tokio::time::Duration::from_secs(5);
3✔
977

978
        let mut interval = tokio::time::interval(poll_interval);
3✔
979
        interval.tick().await;
3✔
980

981
        tracing::debug!("Polling for payment confirmation");
2✔
982

983
        let result = tokio::time::timeout(timeout_duration, async {
2✔
984
            loop {
985
                interval.tick().await;
27✔
986
                let check_result = proposal
26✔
987
                    .check_for_transaction(|txid| {
51✔
988
                        self.wallet()
51✔
989
                            .get_raw_transaction(&txid)
51✔
990
                            .map_err(|e| ImplementationError::from(e.into_boxed_dyn_error()))
51✔
991
                    })
51✔
992
                    .save(persister);
26✔
993

994
                match check_result {
26✔
995
                    Ok(OptionalTransitionOutcome::Progress(())) => {
996
                        println!("Payjoin transaction detected in the mempool!");
1✔
997
                        return Ok(());
1✔
998
                    }
999
                    Ok(OptionalTransitionOutcome::Stasis(_)) => continue,
25✔
1000
                    Err(_) => continue,
×
1001
                }
1002
            }
1003
        })
1✔
1004
        .await;
2✔
1005

1006
        match result {
2✔
1007
            Ok(ok) => ok,
1✔
1008
            Err(_) => Err(anyhow!(
1✔
1009
                "No payjoin transaction detected in mempool within {timeout_duration:?}, stopping."
1✔
1010
            )),
1✔
1011
        }
1012
    }
2✔
1013

1014
    /// Handle error by attempting to send an error response over the directory
1015
    async fn handle_error(
×
1016
        &self,
×
NEW
1017
        mut session: Receiver<HasReplyableError>,
×
1018
        persister: &ReceiverPersister,
×
1019
    ) -> Result<()> {
×
NEW
1020
        let session_id = persister.session_id();
×
NEW
1021
        for retries in 0..3 {
×
NEW
1022
            let (err_response, err_ctx) = self
×
NEW
1023
                .post_via_relay(|relay| {
×
NEW
1024
                    session
×
NEW
1025
                        .create_error_request(relay)
×
NEW
1026
                        .map_err(|e| anyhow!("Failed to post error request: {}", e))
×
NEW
1027
                })
×
NEW
1028
                .await?;
×
1029

NEW
1030
            let err_bytes = match err_response.bytes().await {
×
NEW
1031
                Ok(bytes) => bytes,
×
NEW
1032
                Err(e) => return Err(anyhow!("Failed to get error response bytes: {}", e)),
×
1033
            };
1034

NEW
1035
            match session.process_error_response(&err_bytes, err_ctx).save(persister) {
×
1036
                Ok(TerminalSuccessOutcome::Advance(_)) => {
NEW
1037
                    println!("Error reply delivered. Run `payjoin-cli cancel {session_id}` to broadcast the fallback transaction.");
×
NEW
1038
                    return Ok(());
×
1039
                }
1040
                Ok(TerminalSuccessOutcome::Terminate) => {
NEW
1041
                    println!("Session {session_id} aborted. No fallback transaction available.");
×
NEW
1042
                    return Ok(());
×
1043
                }
NEW
1044
                Ok(TerminalSuccessOutcome::Transient(current_session, err)) if retries < 2 => {
×
NEW
1045
                    tracing::warn!("Transient error posting error reply: {err}; retrying");
×
NEW
1046
                    session = current_session;
×
NEW
1047
                    continue;
×
1048
                }
NEW
1049
                Err(e) => {
×
NEW
1050
                    if let Some(api_err) = e.api_error_ref() {
×
NEW
1051
                        tracing::warn!("Failed to confirm error response delivery: {api_err}");
×
NEW
1052
                    }
×
NEW
1053
                    match e.error_state_ref() {
×
1054
                        Some(_) => {
NEW
1055
                            println!("Session {session_id} failed. Run `payjoin-cli cancel {session_id}` to cancel and broadcast the fallback transaction.");
×
NEW
1056
                            return Ok(());
×
1057
                        }
NEW
1058
                        None => return Err(e.into()),
×
1059
                    }
1060
                }
1061
                _ => {
NEW
1062
                    tracing::error!("Failed to post error reply after 3 retries.");
×
NEW
1063
                    return Err(anyhow!("Retry error"));
×
1064
                }
1065
            }
1066
        }
UNCOV
1067
        Ok(())
×
1068
    }
×
1069

1070
    async fn post_request(&self, req: payjoin::Request) -> Result<reqwest::Response> {
10✔
1071
        let http = http_agent(&self.config)?;
10✔
1072
        http.post(req.url)
10✔
1073
            .header("Content-Type", req.content_type)
10✔
1074
            .body(req.body)
10✔
1075
            .send()
10✔
1076
            .await
10✔
1077
            .and_then(|r| r.error_for_status())
5✔
1078
            .context("HTTP request failed")
5✔
1079
    }
5✔
1080

1081
    async fn post_via_relay<F, T, E>(&self, mut build: F) -> Result<(reqwest::Response, T)>
10✔
1082
    where
10✔
1083
        F: FnMut(&str) -> std::result::Result<(payjoin::Request, T), E>,
10✔
1084
        E: Into<anyhow::Error>,
10✔
1085
    {
10✔
1086
        loop {
1087
            let relay = self.mailroom_manager.choose_relay()?;
10✔
1088
            let (req, ctx) = build(relay.as_str()).map_err(Into::into)?;
10✔
1089
            match self.post_request(req).await {
10✔
1090
                Ok(resp) => return Ok((resp, ctx)),
5✔
1091
                Err(e) => {
×
1092
                    tracing::debug!("Request to relay {relay} failed: {e:?}");
×
1093
                    self.mailroom_manager.add_failed_relay(relay);
×
1094
                }
1095
            }
1096
        }
1097
    }
5✔
1098
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc