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

payjoin / rust-payjoin / 18783991185

24 Oct 2025 03:11PM UTC coverage: 83.698% (-0.04%) from 83.736%
18783991185

push

github

web-flow
Fix rpc-error due to quick transaction lookup (#1159)

22 of 32 new or added lines in 1 file covered. (68.75%)

2 existing lines in 1 file now uncovered.

8990 of 10741 relevant lines covered (83.7%)

461.15 hits per line

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

90.84
/payjoin/src/core/receive/v2/mod.rs
1
//! Receive BIP 77 Payjoin v2
2
//!
3
//! This module contains the typestates and helper methods to perform a Payjoin v2 receive.
4
//!
5
//! Receiving Payjoin transactions securely and privately requires the receiver to run safety
6
//! checks on the sender's original proposal, followed by actually making the input and output
7
//! contributions and modifications before sending the Payjoin proposal back to the sender. All
8
//! safety check and contribution/modification logic is identical between Payjoin v1 and v2.
9
//!
10
//! Additionally, this module also provides tools to manage
11
//! multiple Payjoin sessions which the receiver may have in progress at any given time.
12
//! The receiver can pause and resume Payjoin sessions when networking is available by using a
13
//! Payjoin directory as a store-and-forward server, and keep track of the success and failure of past sessions.
14
//!
15
//! See the typestate and function documentation on how to proceed through the receiver protocol
16
//! flow.
17
//!
18
//! For more information on Payjoin v2, see [BIP 77: Async Payjoin](https://github.com/bitcoin/bips/blob/master/bip-0077.md).
19
//!
20
//! ## OHTTP Privacy Warning
21
//! Encapsulated requests whether GET or POST—**must not be retried or reused**.
22
//! Retransmitting the same ciphertext (including via automatic retries) breaks the unlinkability and privacy guarantees of OHTTP,
23
//! as it allows the relay to correlate requests by comparing ciphertexts.
24
//! Note: Even fresh requests may be linkable via metadata (e.g. client IP, request timing),
25
//! but request reuse makes correlation trivial for the relay.
26

27
use std::str::FromStr;
28
use std::time::Duration;
29

30
use bitcoin::hashes::{sha256, Hash};
31
use bitcoin::psbt::Psbt;
32
use bitcoin::{Address, Amount, FeeRate, OutPoint, Script, TxOut, Txid};
33
pub(crate) use error::InternalSessionError;
34
pub use error::SessionError;
35
use serde::de::Deserializer;
36
use serde::{Deserialize, Serialize};
37
pub use session::{replay_event_log, SessionEvent, SessionHistory, SessionOutcome, SessionStatus};
38
use url::Url;
39

40
use super::error::{Error, InputContributionError};
41
use super::{
42
    common, InternalPayloadError, JsonReply, OutputSubstitutionError, ProtocolError, SelectionError,
43
};
44
use crate::error::{InternalReplayError, ReplayError};
45
use crate::hpke::{decrypt_message_a, encrypt_message_b, HpkeKeyPair, HpkePublicKey};
46
use crate::ohttp::{
47
    ohttp_encapsulate, process_get_res, process_post_res, OhttpEncapsulationError, OhttpKeys,
48
};
49
use crate::output_substitution::OutputSubstitution;
50
use crate::persist::{
51
    MaybeFatalOrSuccessTransition, MaybeFatalTransition, MaybeFatalTransitionWithNoResults,
52
    MaybeSuccessTransition, MaybeTransientTransition, NextStateTransition,
53
};
54
use crate::receive::{parse_payload, InputPair, OriginalPayload, PsbtContext};
55
use crate::time::Time;
56
use crate::uri::ShortId;
57
use crate::{ImplementationError, IntoUrl, IntoUrlError, Request, Version};
58

59
mod error;
60
mod session;
61

62
const SUPPORTED_VERSIONS: &[Version] = &[Version::One, Version::Two];
63

64
static TWENTY_FOUR_HOURS_DEFAULT_EXPIRATION: Duration = Duration::from_secs(60 * 60 * 24);
65

66
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67
pub struct SessionContext {
68
    #[serde(deserialize_with = "deserialize_address_assume_checked")]
69
    address: Address,
70
    directory: url::Url,
71
    ohttp_keys: OhttpKeys,
72
    expiration: Time,
73
    amount: Option<Amount>,
74
    receiver_key: HpkeKeyPair,
75
    reply_key: Option<HpkePublicKey>,
76
    max_fee_rate: FeeRate,
77
}
78

79
impl SessionContext {
80
    fn full_relay_url(&self, ohttp_relay: impl IntoUrl) -> Result<Url, InternalSessionError> {
14✔
81
        let relay_base = ohttp_relay.into_url().map_err(InternalSessionError::ParseUrl)?;
14✔
82

83
        // Only reveal scheme and authority to the relay
84
        let directory_base =
14✔
85
            self.directory.join("/").map_err(|e| InternalSessionError::ParseUrl(e.into()))?;
14✔
86

87
        // Append that information as a path to the relay URL
88
        relay_base
14✔
89
            .join(&format!("/{directory_base}"))
14✔
90
            .map_err(|e| InternalSessionError::ParseUrl(e.into()))
14✔
91
    }
14✔
92

93
    /// The mailbox ID where the receiver expects the sender's Original PSBT.
94
    pub(crate) fn proposal_mailbox_id(&self) -> ShortId {
19✔
95
        short_id_from_pubkey(self.receiver_key.public_key())
19✔
96
    }
19✔
97

98
    /// The mailbox ID where replies (the Proposal PSBT or errors) should
99
    /// be sent. For V1 requests this is the same as the proposal mailbox ID.
100
    // FIXME before the UncheckedOriginalPayload typestate is reached, this returns the
101
    // proposal mailbox ID. It doesn't make sense to reply before receiving
102
    // anything from the sender and at that point it's ambiguous whether it's a
103
    // v2 or v1 sender anyway. Ideally this should be impossible leveraging the
104
    // typestate machinery
105
    pub(crate) fn reply_mailbox_id(&self) -> ShortId {
3✔
106
        short_id_from_pubkey(self.reply_key.as_ref().unwrap_or(self.receiver_key.public_key()))
3✔
107
    }
3✔
108
}
109

110
fn deserialize_address_assume_checked<'de, D>(deserializer: D) -> Result<Address, D::Error>
3✔
111
where
3✔
112
    D: Deserializer<'de>,
3✔
113
{
114
    let s = String::deserialize(deserializer)?;
3✔
115
    let address = Address::from_str(&s).map_err(serde::de::Error::custom)?;
3✔
116
    Ok(address.assume_checked())
3✔
117
}
3✔
118

119
fn short_id_from_pubkey(pubkey: &HpkePublicKey) -> ShortId {
25✔
120
    sha256::Hash::hash(&pubkey.to_compressed_bytes()).into()
25✔
121
}
25✔
122

123
/// Represents the various states of a Payjoin receiver session during the protocol flow.
124
/// Each variant parameterizes a `Receiver` with a specific state type.
125
///
126
/// This provides type erasure for the receive session state, allowing for the session to be replayed
127
/// and the state to be updated with the next event over a uniform interface.
128
#[derive(Debug, Clone, PartialEq)]
129
pub enum ReceiveSession {
130
    Initialized(Receiver<Initialized>),
131
    UncheckedOriginalPayload(Receiver<UncheckedOriginalPayload>),
132
    MaybeInputsOwned(Receiver<MaybeInputsOwned>),
133
    MaybeInputsSeen(Receiver<MaybeInputsSeen>),
134
    OutputsUnknown(Receiver<OutputsUnknown>),
135
    WantsOutputs(Receiver<WantsOutputs>),
136
    WantsInputs(Receiver<WantsInputs>),
137
    WantsFeeRange(Receiver<WantsFeeRange>),
138
    ProvisionalProposal(Receiver<ProvisionalProposal>),
139
    PayjoinProposal(Receiver<PayjoinProposal>),
140
    HasReplyableError(Receiver<HasReplyableError>),
141
    Monitor(Receiver<Monitor>),
142
    Closed(SessionOutcome),
143
}
144

145
impl ReceiveSession {
146
    fn new(context: SessionContext) -> Self {
13✔
147
        ReceiveSession::Initialized(Receiver { state: Initialized {}, session_context: context })
13✔
148
    }
13✔
149

150
    fn process_event(
43✔
151
        self,
43✔
152
        event: SessionEvent,
43✔
153
    ) -> Result<ReceiveSession, ReplayError<Self, SessionEvent>> {
43✔
154
        match (self, event) {
43✔
155
            (
156
                ReceiveSession::Initialized(state),
10✔
157
                SessionEvent::RetrievedOriginalPayload { original: proposal, reply_key },
10✔
158
            ) => Ok(state.apply_retrieved_original_payload(proposal, reply_key)),
10✔
159

160
            (
161
                ReceiveSession::UncheckedOriginalPayload(state),
6✔
162
                SessionEvent::CheckedBroadcastSuitability(),
163
            ) => Ok(state.apply_checked_broadcast_suitability()),
6✔
164

165
            (ReceiveSession::MaybeInputsOwned(state), SessionEvent::CheckedInputsNotOwned()) =>
3✔
166
                Ok(state.apply_checked_inputs_not_owned()),
3✔
167

168
            (ReceiveSession::MaybeInputsSeen(state), SessionEvent::CheckedNoInputsSeenBefore()) =>
3✔
169
                Ok(state.apply_checked_no_inputs_seen_before()),
3✔
170

171
            (
172
                ReceiveSession::OutputsUnknown(state),
3✔
173
                SessionEvent::IdentifiedReceiverOutputs(wants_outputs),
3✔
174
            ) => Ok(state.apply_identified_receiver_outputs(wants_outputs)),
3✔
175

176
            (ReceiveSession::WantsOutputs(state), SessionEvent::CommittedOutputs(wants_inputs)) =>
3✔
177
                Ok(state.apply_committed_outputs(wants_inputs)),
3✔
178

179
            (
180
                ReceiveSession::WantsInputs(state),
3✔
181
                SessionEvent::CommittedInputs(wants_fee_range),
3✔
182
            ) => Ok(state.apply_committed_inputs(wants_fee_range)),
3✔
183

184
            (ReceiveSession::WantsFeeRange(state), SessionEvent::AppliedFeeRange(psbt_context)) =>
3✔
185
                Ok(state.apply_applied_fee_range(psbt_context)),
3✔
186

187
            (
188
                ReceiveSession::ProvisionalProposal(state),
2✔
189
                SessionEvent::FinalizedProposal(payjoin_proposal),
2✔
190
            ) => Ok(state.apply_payjoin_proposal(payjoin_proposal)),
2✔
191

192
            (ReceiveSession::PayjoinProposal(state), SessionEvent::PostedPayjoinProposal()) =>
1✔
193
                Ok(state.apply_payjoin_posted()),
1✔
194

195
            (_, SessionEvent::Closed(session_outcome)) =>
3✔
196
                Ok(ReceiveSession::Closed(session_outcome)),
3✔
197

198
            (session, SessionEvent::GotReplyableError(error)) =>
3✔
199
                Ok(ReceiveSession::HasReplyableError(Receiver {
200
                    state: HasReplyableError { error_reply: error.clone() },
3✔
201
                    session_context: match session {
3✔
202
                        ReceiveSession::Initialized(r) => r.session_context,
×
203
                        ReceiveSession::UncheckedOriginalPayload(r) => r.session_context,
1✔
204
                        ReceiveSession::MaybeInputsOwned(r) => r.session_context,
2✔
205
                        ReceiveSession::MaybeInputsSeen(r) => r.session_context,
×
206
                        ReceiveSession::OutputsUnknown(r) => r.session_context,
×
207
                        ReceiveSession::WantsOutputs(r) => r.session_context,
×
208
                        ReceiveSession::WantsInputs(r) => r.session_context,
×
209
                        ReceiveSession::WantsFeeRange(r) => r.session_context,
×
210
                        ReceiveSession::ProvisionalProposal(r) => r.session_context,
×
211
                        ReceiveSession::PayjoinProposal(r) => r.session_context,
×
212
                        ReceiveSession::HasReplyableError(r) => r.session_context,
×
213
                        ReceiveSession::Monitor(r) => r.session_context,
×
214
                        ReceiveSession::Closed(session_outcome) =>
×
215
                            return Ok(ReceiveSession::Closed(session_outcome)),
×
216
                    },
217
                })),
218

219
            (current_state, event) => Err(InternalReplayError::InvalidEvent(
×
220
                Box::new(event),
×
221
                Some(Box::new(current_state)),
×
222
            )
×
223
            .into()),
×
224
        }
225
    }
43✔
226
}
227

228
mod sealed {
229
    pub trait State {}
230

231
    impl State for super::Initialized {}
232
    impl State for super::UncheckedOriginalPayload {}
233
    impl State for super::MaybeInputsOwned {}
234
    impl State for super::MaybeInputsSeen {}
235
    impl State for super::OutputsUnknown {}
236
    impl State for super::WantsOutputs {}
237
    impl State for super::WantsInputs {}
238
    impl State for super::WantsFeeRange {}
239
    impl State for super::ProvisionalProposal {}
240
    impl State for super::PayjoinProposal {}
241
    impl State for super::HasReplyableError {}
242
    impl State for super::Monitor {}
243
}
244

245
/// Sealed trait for V2 receive session states.
246
///
247
/// Any typestate should implement this trait to be considered a part of the protocol flow.
248
/// This trait is sealed to prevent external implementations. Only types within this crate
249
/// can implement this trait, ensuring type safety and protocol integrity.
250
pub trait State: sealed::State {}
251

252
/// A higher-level receiver construct which will be taken through different states through the
253
/// protocol workflow.
254
///
255
/// A Payjoin receiver is responsible for receiving the original proposal from the sender, making
256
/// various safety checks, contributing and/or changing inputs and outputs, and sending the Payjoin
257
/// proposal back to the sender before they sign off on the receiver's contributions and broadcast
258
/// the transaction.
259
///
260
/// From a code/implementation perspective, Payjoin Development Kit uses a typestate pattern to
261
/// help receivers go through the entire Payjoin protocol flow. Each typestate has
262
/// various functions to accomplish the goals of the typestate, and one or more functions which
263
/// will commit the changes/checks in the current typestate and move to the next one. For more
264
/// information on the typestate pattern, see [The Typestate Pattern in Rust](https://cliffle.com/blog/rust-typestate/).
265
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
266
pub struct Receiver<State> {
267
    /// Data associated with the current state of the receiver.
268
    pub(crate) state: State,
269
    pub(crate) session_context: SessionContext,
270
}
271

272
impl<State> core::ops::Deref for Receiver<State> {
273
    type Target = State;
274

275
    fn deref(&self) -> &Self::Target { &self.state }
59✔
276
}
277

278
impl<State> core::ops::DerefMut for Receiver<State> {
279
    fn deref_mut(&mut self) -> &mut Self::Target { &mut self.state }
×
280
}
281

282
#[derive(Debug, Clone)]
283
pub struct ReceiverBuilder(SessionContext);
284

285
impl ReceiverBuilder {
286
    /// Creates a new [`ReceiverBuilder`] with the provided parameters.
287
    ///
288
    /// This is the beginning of the receiver protocol in Payjoin v2. It uses the passed address,
289
    /// store-and-forward Payjoin directory URL, and the OHTTP keys to encrypt and decrypt HTTP
290
    /// requests and responses to initialize a Payjoin v2 session.
291
    ///
292
    /// Expiration time can be optionally defined to set when the session expires (due to
293
    /// inactivity of either party, etc.) or otherwise set to a default of 24 hours.
294
    ///
295
    /// See [BIP 77: Payjoin Version 2: Serverless Payjoin](https://github.com/bitcoin/bips/blob/master/bip-0077.md)
296
    /// for more information on the purpose of each parameter for secure Payjoin v2 functionality.
297
    pub fn new(
11✔
298
        address: Address,
11✔
299
        directory: impl IntoUrl,
11✔
300
        ohttp_keys: OhttpKeys,
11✔
301
    ) -> Result<Self, IntoUrlError> {
11✔
302
        let directory = directory.into_url()?;
11✔
303
        let session_context = SessionContext {
11✔
304
            address,
11✔
305
            directory,
11✔
306
            ohttp_keys,
11✔
307
            receiver_key: HpkeKeyPair::gen_keypair(),
11✔
308
            expiration: Time::from_now(TWENTY_FOUR_HOURS_DEFAULT_EXPIRATION)
11✔
309
                .expect("Default expiration time should be representable as u32 unix time"),
11✔
310
            amount: None,
11✔
311
            reply_key: None,
11✔
312
            max_fee_rate: FeeRate::BROADCAST_MIN,
11✔
313
        };
11✔
314
        Ok(Self(session_context))
11✔
315
    }
11✔
316

317
    pub fn with_expiration(self, expiration: Duration) -> Self {
2✔
318
        Self(SessionContext {
2✔
319
            expiration: Time::from_now(expiration)
2✔
320
                .expect("specifying expiration as Duration should not fail"),
2✔
321
            ..self.0
2✔
322
        })
2✔
323
    }
2✔
324

325
    pub fn with_amount(self, amount: Amount) -> Self {
1✔
326
        Self(SessionContext { amount: Some(amount), ..self.0 })
1✔
327
    }
1✔
328

329
    /// Set the maximum effective fee rate the receiver is willing to pay for their own input/output contributions
330
    pub fn with_max_fee_rate(self, max_fee_rate: FeeRate) -> Self {
2✔
331
        Self(SessionContext { max_fee_rate, ..self.0 })
2✔
332
    }
2✔
333

334
    pub fn build(self) -> NextStateTransition<SessionEvent, Receiver<Initialized>> {
11✔
335
        NextStateTransition::success(
11✔
336
            SessionEvent::Created(self.0.clone()),
11✔
337
            Receiver { state: Initialized {}, session_context: self.0 },
11✔
338
        )
339
    }
11✔
340
}
341

342
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
343
pub struct Initialized {}
344

345
impl Receiver<Initialized> {
346
    /// construct an OHTTP Encapsulated HTTP GET request for the Original PSBT
347
    pub fn create_poll_request(
9✔
348
        &self,
9✔
349
        ohttp_relay: impl IntoUrl,
9✔
350
    ) -> Result<(Request, ohttp::ClientResponse), Error> {
9✔
351
        if self.session_context.expiration.elapsed() {
9✔
352
            return Err(InternalSessionError::Expired(self.session_context.expiration).into());
1✔
353
        }
8✔
354
        let (body, ohttp_ctx) =
8✔
355
            self.fallback_req_body().map_err(InternalSessionError::OhttpEncapsulation)?;
8✔
356
        let req = Request::new_v2(&self.session_context.full_relay_url(ohttp_relay)?, &body);
8✔
357
        Ok((req, ohttp_ctx))
8✔
358
    }
9✔
359

360
    /// The response can either be an UncheckedOriginalPayload or an ACCEPTED message
361
    /// indicating no UncheckedOriginalPayload is available yet.
362
    pub fn process_response(
6✔
363
        self,
6✔
364
        body: &[u8],
6✔
365
        context: ohttp::ClientResponse,
6✔
366
    ) -> MaybeFatalTransitionWithNoResults<
6✔
367
        SessionEvent,
6✔
368
        Receiver<UncheckedOriginalPayload>,
6✔
369
        Receiver<Initialized>,
6✔
370
        ProtocolError,
6✔
371
    > {
6✔
372
        let current_state = self.clone();
6✔
373
        let proposal = match self.inner_process_res(body, context) {
6✔
374
            Ok(proposal) => proposal,
6✔
375
            Err(e) => match e {
×
376
                ProtocolError::V2(SessionError(InternalSessionError::DirectoryResponse(
377
                    ref directory_error,
×
378
                ))) =>
379
                    if directory_error.is_fatal() {
×
380
                        return MaybeFatalTransitionWithNoResults::fatal(
×
381
                            SessionEvent::Closed(SessionOutcome::Failure),
×
382
                            e,
×
383
                        );
384
                    } else {
385
                        return MaybeFatalTransitionWithNoResults::transient(e);
×
386
                    },
387
                _ =>
388
                    return MaybeFatalTransitionWithNoResults::fatal(
×
389
                        SessionEvent::Closed(SessionOutcome::Failure),
×
390
                        e,
×
391
                    ),
392
            },
393
        };
394

395
        if let Some((proposal, reply_key)) = proposal {
6✔
396
            MaybeFatalTransitionWithNoResults::success(
4✔
397
                SessionEvent::RetrievedOriginalPayload {
4✔
398
                    original: proposal.clone(),
4✔
399
                    reply_key: reply_key.clone(),
4✔
400
                },
4✔
401
                Receiver {
4✔
402
                    state: UncheckedOriginalPayload { original: proposal },
4✔
403
                    session_context: SessionContext { reply_key, ..current_state.session_context },
4✔
404
                },
4✔
405
            )
406
        } else {
407
            MaybeFatalTransitionWithNoResults::no_results(current_state)
2✔
408
        }
409
    }
6✔
410

411
    fn inner_process_res(
6✔
412
        self,
6✔
413
        body: &[u8],
6✔
414
        context: ohttp::ClientResponse,
6✔
415
    ) -> Result<Option<(OriginalPayload, Option<HpkePublicKey>)>, ProtocolError> {
6✔
416
        let body = match process_get_res(body, context)
6✔
417
            .map_err(|e| ProtocolError::V2(InternalSessionError::DirectoryResponse(e).into()))?
6✔
418
        {
419
            Some(body) => body,
4✔
420
            None => return Ok(None),
2✔
421
        };
422
        match std::str::from_utf8(&body) {
4✔
423
            // V1 response bodies are utf8 plaintext
424
            Ok(response) =>
1✔
425
                Ok(Some(self.extract_proposal_from_v1(response).map(|original| (original, None))?)),
1✔
426
            // V2 response bodies are encrypted binary
427
            Err(_) => Ok(Some(
428
                self.extract_proposal_from_v2(body)
3✔
429
                    .map(|(original, reply_key)| (original, Some(reply_key)))?,
3✔
430
            )),
431
        }
432
    }
6✔
433

434
    fn fallback_req_body(
8✔
435
        &self,
8✔
436
    ) -> Result<
8✔
437
        ([u8; crate::directory::ENCAPSULATED_MESSAGE_BYTES], ohttp::ClientResponse),
8✔
438
        OhttpEncapsulationError,
8✔
439
    > {
8✔
440
        let fallback_target = mailbox_endpoint(
8✔
441
            &self.session_context.directory,
8✔
442
            &self.session_context.proposal_mailbox_id(),
8✔
443
        );
444
        ohttp_encapsulate(&self.session_context.ohttp_keys, "GET", fallback_target.as_str(), None)
8✔
445
    }
8✔
446

447
    fn extract_proposal_from_v1(self, response: &str) -> Result<OriginalPayload, ProtocolError> {
1✔
448
        self.unchecked_from_payload(response)
1✔
449
    }
1✔
450

451
    fn extract_proposal_from_v2(
3✔
452
        self,
3✔
453
        response: Vec<u8>,
3✔
454
    ) -> Result<(OriginalPayload, HpkePublicKey), ProtocolError> {
3✔
455
        let (payload_bytes, reply_key) =
3✔
456
            decrypt_message_a(&response, self.session_context.receiver_key.secret_key().clone())
3✔
457
                .map_err(|e| ProtocolError::V2(InternalSessionError::Hpke(e).into()))?;
3✔
458
        let payload = std::str::from_utf8(&payload_bytes)
3✔
459
            .map_err(|e| ProtocolError::OriginalPayload(InternalPayloadError::Utf8(e).into()))?;
3✔
460
        self.unchecked_from_payload(payload).map(|p| (p, reply_key))
3✔
461
    }
3✔
462

463
    fn unchecked_from_payload(self, payload: &str) -> Result<OriginalPayload, ProtocolError> {
5✔
464
        let (base64, padded_query) = payload.split_once('\n').unwrap_or_default();
5✔
465
        let query = padded_query.trim_matches('\0');
5✔
466
        tracing::trace!("Received query: {query}, base64: {base64}"); // my guess is no \n so default is wrong
5✔
467
        let (psbt, mut params) = parse_payload(base64, query, SUPPORTED_VERSIONS)
5✔
468
            .map_err(ProtocolError::OriginalPayload)?;
5✔
469

470
        // Output substitution must be disabled for V1 sessions in V2 contexts.
471
        //
472
        // V2 contexts depend on a payjoin directory to store and forward payjoin
473
        // proposals. Plaintext V1 proposals are vulnerable to output replacement
474
        // attacks by a malicious directory if output substitution is not disabled.
475
        // V2 proposals are authenticated and encrypted to prevent such attacks.
476
        //
477
        // see: https://github.com/bitcoin/bips/blob/master/bip-0078.mediawiki#unsecured-payjoin-server
478
        if params.v == Version::One {
5✔
479
            params.output_substitution = OutputSubstitution::Disabled;
2✔
480
        }
3✔
481

482
        let inner = OriginalPayload { psbt, params };
5✔
483
        Ok(inner)
5✔
484
    }
5✔
485

486
    /// Build a V2 Payjoin URI from the receiver's context
487
    pub fn pj_uri<'a>(&self) -> crate::PjUri<'a> {
10✔
488
        pj_uri(&self.session_context, OutputSubstitution::Disabled)
10✔
489
    }
10✔
490

491
    pub(crate) fn apply_retrieved_original_payload(
10✔
492
        self,
10✔
493
        event: OriginalPayload,
10✔
494
        reply_key: Option<HpkePublicKey>,
10✔
495
    ) -> ReceiveSession {
10✔
496
        let new_state = Receiver {
10✔
497
            state: UncheckedOriginalPayload { original: event },
10✔
498
            session_context: SessionContext { reply_key, ..self.session_context },
10✔
499
        };
10✔
500

501
        ReceiveSession::UncheckedOriginalPayload(new_state)
10✔
502
    }
10✔
503
}
504

505
/// The sender's original PSBT and optional parameters
506
///
507
/// This type is used to process the request. It is returned by
508
/// [`Receiver::process_response()`].
509
///
510
#[derive(Debug, Clone, PartialEq)]
511
pub struct UncheckedOriginalPayload {
512
    pub(crate) original: OriginalPayload,
513
}
514

515
/// The original PSBT and the optional parameters received from the sender.
516
///
517
/// This is the first typestate after the retrieval of the sender's original proposal in
518
/// the receiver's workflow. At this stage, the receiver can verify that the original PSBT they have
519
/// received from the sender is broadcastable to the network in the case of a payjoin failure.
520
///
521
/// The recommended usage of this typestate differs based on whether you are implementing an
522
/// interactive (where the receiver takes manual actions to respond to the
523
/// payjoin proposal) or a non-interactive (ex. a donation page which automatically generates a new QR code
524
/// for each visit) payment receiver. For the latter, you should call [`Receiver<UncheckedOriginalPayload>::check_broadcast_suitability`] to check
525
/// that the proposal is actually broadcastable (and, optionally, whether the fee rate is above the
526
/// minimum limit you have set). These mechanisms protect the receiver against probing attacks, where
527
/// a malicious sender can repeatedly send proposals to have the non-interactive receiver reveal the UTXOs
528
/// it owns with the proposals it modifies.
529
///
530
/// If you are implementing an interactive payment receiver, then such checks are not necessary, and you
531
/// can go ahead with calling [`Receiver<UncheckedOriginalPayload>::assume_interactive_receiver`] to move on to the next typestate.
532
impl Receiver<UncheckedOriginalPayload> {
533
    /// Checks that the original PSBT in the proposal can be broadcasted.
534
    ///
535
    /// If the receiver is a non-interactive payment processor (ex. a donation page which generates
536
    /// a new QR code for each visit), then it should make sure that the original PSBT is broadcastable
537
    /// as a fallback mechanism in case the payjoin fails. This validation would be equivalent to
538
    /// `testmempoolaccept` RPC call returning `{"allowed": true,...}`.
539
    ///
540
    /// Receiver can optionally set a minimum fee rate which will be enforced on the original PSBT in the proposal.
541
    /// This can be used to further prevent probing attacks since the attacker would now need to probe the receiver
542
    /// with transactions which are both broadcastable and pay high fee. Unrelated to the probing attack scenario,
543
    /// this parameter also makes operating in a high fee environment easier for the receiver.
544
    pub fn check_broadcast_suitability(
10✔
545
        self,
10✔
546
        min_fee_rate: Option<FeeRate>,
10✔
547
        can_broadcast: impl Fn(&bitcoin::Transaction) -> Result<bool, ImplementationError>,
10✔
548
    ) -> MaybeFatalTransition<
10✔
549
        SessionEvent,
10✔
550
        Receiver<MaybeInputsOwned>,
10✔
551
        Error,
10✔
552
        Receiver<HasReplyableError>,
10✔
553
    > {
10✔
554
        match self.state.original.check_broadcast_suitability(min_fee_rate, can_broadcast) {
10✔
555
            Ok(()) => MaybeFatalTransition::success(
3✔
556
                SessionEvent::CheckedBroadcastSuitability(),
3✔
557
                Receiver {
3✔
558
                    state: MaybeInputsOwned { original: self.original.clone() },
3✔
559
                    session_context: self.session_context,
3✔
560
                },
3✔
561
            ),
562
            Err(Error::Implementation(e)) =>
5✔
563
                MaybeFatalTransition::transient(Error::Implementation(e)),
5✔
564
            Err(e) => MaybeFatalTransition::replyable_error(
2✔
565
                SessionEvent::GotReplyableError((&e).into()),
2✔
566
                Receiver {
2✔
567
                    state: HasReplyableError { error_reply: (&e).into() },
2✔
568
                    session_context: self.session_context,
2✔
569
                },
2✔
570
                e,
2✔
571
            ),
572
        }
573
    }
10✔
574

575
    /// Moves on to the next typestate without any of the current typestate's validations.
576
    ///
577
    /// Use this for interactive payment receivers, where there is no risk of a probing attack since the
578
    /// receiver needs to manually create payjoin URIs.
579
    pub fn assume_interactive_receiver(
8✔
580
        self,
8✔
581
    ) -> NextStateTransition<SessionEvent, Receiver<MaybeInputsOwned>> {
8✔
582
        NextStateTransition::success(
8✔
583
            SessionEvent::CheckedBroadcastSuitability(),
8✔
584
            Receiver {
8✔
585
                state: MaybeInputsOwned { original: self.original.clone() },
8✔
586
                session_context: self.session_context,
8✔
587
            },
8✔
588
        )
589
    }
8✔
590

591
    pub(crate) fn apply_checked_broadcast_suitability(self) -> ReceiveSession {
6✔
592
        let new_state = Receiver {
6✔
593
            state: MaybeInputsOwned { original: self.original.clone() },
6✔
594
            session_context: self.session_context,
6✔
595
        };
6✔
596
        ReceiveSession::MaybeInputsOwned(new_state)
6✔
597
    }
6✔
598
}
599

600
#[derive(Debug, Clone, PartialEq)]
601
pub struct MaybeInputsOwned {
602
    original: OriginalPayload,
603
}
604

605
/// Typestate to check that the original PSBT has no inputs owned by the receiver.
606
///
607
/// At this point, it has been verified that the transaction is broadcastable from previous
608
/// typestate. The receiver can call [`Receiver<MaybeInputsOwned>::extract_tx_to_schedule_broadcast`]
609
/// to extract the signed original PSBT to schedule a fallback in case the Payjoin process fails.
610
///
611
/// Call [`Receiver<MaybeInputsOwned>::check_inputs_not_owned`] to proceed.
612
impl Receiver<MaybeInputsOwned> {
613
    /// Extracts the original transaction received from the sender.
614
    ///
615
    /// Use this for scheduling the broadcast of the original transaction as a fallback
616
    /// for the payjoin. Note that this function does not make any validation on whether
617
    /// the transaction is broadcastable; it simply extracts it.
618
    pub fn extract_tx_to_schedule_broadcast(&self) -> bitcoin::Transaction {
6✔
619
        self.original.psbt.clone().extract_tx_unchecked_fee_rate()
6✔
620
    }
6✔
621

622
    /// Check that the original PSBT has no receiver-owned inputs.
623
    ///
624
    /// An attacker can try to spend the receiver's own inputs. This check prevents that.
625
    pub fn check_inputs_not_owned(
11✔
626
        self,
11✔
627
        is_owned: &mut impl FnMut(&Script) -> Result<bool, ImplementationError>,
11✔
628
    ) -> MaybeFatalTransition<
11✔
629
        SessionEvent,
11✔
630
        Receiver<MaybeInputsSeen>,
11✔
631
        Error,
11✔
632
        Receiver<HasReplyableError>,
11✔
633
    > {
11✔
634
        match self.state.original.check_inputs_not_owned(is_owned) {
11✔
635
            Ok(inner) => inner,
9✔
636
            Err(e) => match e {
2✔
637
                Error::Implementation(_) => {
638
                    return MaybeFatalTransition::transient(e);
1✔
639
                }
640
                _ => {
641
                    return MaybeFatalTransition::replyable_error(
1✔
642
                        SessionEvent::GotReplyableError((&e).into()),
1✔
643
                        Receiver {
1✔
644
                            state: HasReplyableError { error_reply: (&e).into() },
1✔
645
                            session_context: self.session_context,
1✔
646
                        },
1✔
647
                        e,
1✔
648
                    );
649
                }
650
            },
651
        };
652
        MaybeFatalTransition::success(
9✔
653
            SessionEvent::CheckedInputsNotOwned(),
9✔
654
            Receiver {
9✔
655
                state: MaybeInputsSeen { original: self.original.clone() },
9✔
656
                session_context: self.session_context,
9✔
657
            },
9✔
658
        )
659
    }
11✔
660

661
    pub(crate) fn apply_checked_inputs_not_owned(self) -> ReceiveSession {
3✔
662
        let new_state = Receiver {
3✔
663
            state: MaybeInputsSeen { original: self.original.clone() },
3✔
664
            session_context: self.session_context,
3✔
665
        };
3✔
666
        ReceiveSession::MaybeInputsSeen(new_state)
3✔
667
    }
3✔
668
}
669

670
#[derive(Debug, Clone, PartialEq)]
671
pub struct MaybeInputsSeen {
672
    original: OriginalPayload,
673
}
674

675
/// Typestate to check that the original PSBT has no inputs that the receiver has seen before.
676
///
677
/// Call [`Receiver<MaybeInputsSeen>::check_no_inputs_seen_before`] to proceed.
678
impl Receiver<MaybeInputsSeen> {
679
    /// Check that the receiver has never seen the inputs in the original proposal before.
680
    ///
681
    /// This check prevents the following attacks:
682
    /// 1. Probing attacks, where the sender can use the exact same proposal (or with minimal change)
683
    ///    to have the receiver reveal their UTXO set by contributing to all proposals with different inputs
684
    ///    and sending them back to the receiver.
685
    /// 2. Re-entrant payjoin, where the sender uses the payjoin PSBT of a previous payjoin as the
686
    ///    original proposal PSBT of the current, new payjoin.
687
    pub fn check_no_inputs_seen_before(
9✔
688
        self,
9✔
689
        is_known: &mut impl FnMut(&OutPoint) -> Result<bool, ImplementationError>,
9✔
690
    ) -> MaybeFatalTransition<
9✔
691
        SessionEvent,
9✔
692
        Receiver<OutputsUnknown>,
9✔
693
        Error,
9✔
694
        Receiver<HasReplyableError>,
9✔
695
    > {
9✔
696
        match self.state.original.check_no_inputs_seen_before(is_known) {
9✔
697
            Ok(inner) => inner,
8✔
698
            Err(e) => match e {
1✔
699
                Error::Implementation(_) => {
700
                    return MaybeFatalTransition::transient(e);
1✔
701
                }
702
                _ => {
703
                    return MaybeFatalTransition::replyable_error(
×
704
                        SessionEvent::GotReplyableError((&e).into()),
×
705
                        Receiver {
×
706
                            state: HasReplyableError { error_reply: (&e).into() },
×
707
                            session_context: self.session_context,
×
708
                        },
×
709
                        e,
×
710
                    );
711
                }
712
            },
713
        };
714
        MaybeFatalTransition::success(
8✔
715
            SessionEvent::CheckedNoInputsSeenBefore(),
8✔
716
            Receiver {
8✔
717
                state: OutputsUnknown { original: self.original.clone() },
8✔
718
                session_context: self.session_context,
8✔
719
            },
8✔
720
        )
721
    }
9✔
722

723
    pub(crate) fn apply_checked_no_inputs_seen_before(self) -> ReceiveSession {
3✔
724
        let new_state = Receiver {
3✔
725
            state: OutputsUnknown { original: self.original.clone() },
3✔
726
            session_context: self.session_context,
3✔
727
        };
3✔
728
        ReceiveSession::OutputsUnknown(new_state)
3✔
729
    }
3✔
730
}
731

732
#[derive(Debug, Clone, PartialEq)]
733
pub struct OutputsUnknown {
734
    original: OriginalPayload,
735
}
736

737
/// Typestate to check that the outputs of the original PSBT actually pay to the receiver.
738
///
739
/// The receiver should only accept the original PSBTs from the sender which actually send them
740
/// money.
741
///
742
/// Call [`Receiver<OutputsUnknown>::identify_receiver_outputs`] to proceed.
743
impl Receiver<OutputsUnknown> {
744
    /// Validates whether the original PSBT contains outputs which pay to the receiver and only
745
    /// then proceeds to the next typestate.
746
    ///
747
    /// Additionally, this function also protects the receiver from accidentally subtracting fees
748
    /// from their own outputs: when a sender is sending a proposal,
749
    /// they can select an output which they want the receiver to subtract fees from to account for
750
    /// the increased transaction size. If a sender specifies a receiver output for this purpose, this
751
    /// function sets that parameter to None so that it is ignored in subsequent steps of the
752
    /// receiver flow. This protects the receiver from accidentally subtracting fees from their own
753
    /// outputs.
754
    pub fn identify_receiver_outputs(
8✔
755
        self,
8✔
756
        is_receiver_output: &mut impl FnMut(&Script) -> Result<bool, ImplementationError>,
8✔
757
    ) -> MaybeFatalTransition<
8✔
758
        SessionEvent,
8✔
759
        Receiver<WantsOutputs>,
8✔
760
        Error,
8✔
761
        Receiver<HasReplyableError>,
8✔
762
    > {
8✔
763
        let inner = match self.state.original.identify_receiver_outputs(is_receiver_output) {
8✔
764
            Ok(inner) => inner,
7✔
765
            Err(e) => match e {
1✔
766
                Error::Implementation(_) => {
767
                    return MaybeFatalTransition::transient(e);
1✔
768
                }
769
                _ => {
770
                    return MaybeFatalTransition::replyable_error(
×
771
                        SessionEvent::GotReplyableError((&e).into()),
×
772
                        Receiver {
×
773
                            state: HasReplyableError { error_reply: (&e).into() },
×
774
                            session_context: self.session_context,
×
775
                        },
×
776
                        e,
×
777
                    );
778
                }
779
            },
780
        };
781
        MaybeFatalTransition::success(
7✔
782
            SessionEvent::IdentifiedReceiverOutputs(inner.owned_vouts.clone()),
7✔
783
            Receiver { state: WantsOutputs { inner }, session_context: self.session_context },
7✔
784
        )
785
    }
8✔
786

787
    pub(crate) fn apply_identified_receiver_outputs(
3✔
788
        self,
3✔
789
        owned_vouts: Vec<usize>,
3✔
790
    ) -> ReceiveSession {
3✔
791
        let inner = common::WantsOutputs::new(self.state.original, owned_vouts);
3✔
792
        let new_state =
3✔
793
            Receiver { state: WantsOutputs { inner }, session_context: self.session_context };
3✔
794
        ReceiveSession::WantsOutputs(new_state)
3✔
795
    }
3✔
796
}
797

798
#[derive(Debug, Clone, PartialEq)]
799
pub struct WantsOutputs {
800
    inner: common::WantsOutputs,
801
}
802

803
/// Typestate which the receiver may substitute or add outputs to.
804
///
805
/// In addition to contributing new inputs to an existing PSBT, Payjoin allows the
806
/// receiver to substitute the original PSBT's outputs to potentially preserve privacy and batch transfers.
807
/// The receiver does not have to limit themselves to the address shared with the sender in the
808
/// original Payjoin URI, and can make substitutions of the existing outputs in the proposal.
809
///
810
/// Call [`Receiver<WantsOutputs>::commit_outputs`] to proceed.
811
impl Receiver<WantsOutputs> {
812
    /// Whether the receiver is allowed to substitute original outputs or not.
813
    pub fn output_substitution(&self) -> OutputSubstitution { self.inner.output_substitution() }
×
814

815
    /// Substitute the receiver output script with the provided script.
816
    pub fn substitute_receiver_script(
×
817
        self,
×
818
        output_script: &Script,
×
819
    ) -> Result<Self, OutputSubstitutionError> {
×
820
        let inner = self.state.inner.substitute_receiver_script(output_script)?;
×
821
        Ok(Receiver { state: WantsOutputs { inner }, session_context: self.session_context })
×
822
    }
×
823

824
    /// Replaces **all** receiver outputs with the one or more provided `replacement_outputs`, and
825
    /// sets up the passed `drain_script` as the receiver-owned output which might have its value
826
    /// adjusted based on the modifications the receiver makes in the subsequent typestates.
827
    ///
828
    /// Sender's outputs are not touched. Existing receiver outputs will be replaced with the
829
    /// outputs in the `replacement_outputs` argument. The number of replacement outputs should
830
    /// match or exceed the number of receiver outputs in the original proposal PSBT.
831
    ///
832
    /// The drain script is the receiver script which will have its value adjusted based on the
833
    /// modifications the receiver makes on the transaction in the subsequent typestates. For
834
    /// example, if the receiver adds their own input, then the drain script output will have its
835
    /// value increased by the same amount. Or if an output needs to have its value reduced to
836
    /// account for fees, the value of the output for this script will be reduced.
837
    pub fn replace_receiver_outputs(
×
838
        self,
×
839
        replacement_outputs: impl IntoIterator<Item = TxOut>,
×
840
        drain_script: &Script,
×
841
    ) -> Result<Self, OutputSubstitutionError> {
×
842
        let inner = self.state.inner.replace_receiver_outputs(replacement_outputs, drain_script)?;
×
843
        Ok(Receiver { state: WantsOutputs { inner }, session_context: self.session_context })
×
844
    }
×
845

846
    /// Commits the outputs as final, and moves on to the next typestate.
847
    ///
848
    /// Outputs cannot be modified after this function is called.
849
    pub fn commit_outputs(self) -> NextStateTransition<SessionEvent, Receiver<WantsInputs>> {
6✔
850
        let inner = self.state.inner.clone().commit_outputs();
6✔
851
        NextStateTransition::success(
6✔
852
            SessionEvent::CommittedOutputs(self.state.inner.payjoin_psbt.unsigned_tx.output),
6✔
853
            Receiver { state: WantsInputs { inner }, session_context: self.session_context },
6✔
854
        )
855
    }
6✔
856

857
    pub(crate) fn apply_committed_outputs(self, outputs: Vec<TxOut>) -> ReceiveSession {
3✔
858
        let mut payjoin_proposal = self.inner.payjoin_psbt.clone();
3✔
859
        let outputs_len = outputs.len();
3✔
860
        // Add the outputs that may have been replaced
861
        payjoin_proposal.unsigned_tx.output = outputs;
3✔
862
        payjoin_proposal.outputs = vec![Default::default(); outputs_len];
3✔
863

864
        let mut inner = self.state.inner.commit_outputs();
3✔
865
        inner.payjoin_psbt = payjoin_proposal;
3✔
866

867
        let new_state =
3✔
868
            Receiver { state: WantsInputs { inner }, session_context: self.session_context };
3✔
869
        ReceiveSession::WantsInputs(new_state)
3✔
870
    }
3✔
871
}
872

873
#[derive(Debug, Clone, PartialEq)]
874
pub struct WantsInputs {
875
    inner: common::WantsInputs,
876
}
877

878
/// Typestate for a checked proposal which the receiver may contribute inputs to.
879
///
880
/// Call [`Receiver<WantsInputs>::commit_inputs`] to proceed.
881
impl Receiver<WantsInputs> {
882
    /// Selects and returns an input from `candidate_inputs` which will preserve the receiver's privacy by
883
    /// avoiding the Unnecessary Input Heuristic 2 (UIH2) outlined in [Unnecessary Input
884
    /// Heuristics and PayJoin Transactions by Ghesmati et al. (2022)](https://eprint.iacr.org/2022/589).
885
    ///
886
    /// Privacy preservation is only supported for 2-output transactions. If the PSBT has more than
887
    /// 2 outputs or if none of the candidates are suitable for avoiding UIH2, this function
888
    /// defaults to the first candidate in `candidate_inputs` list.
889
    pub fn try_preserving_privacy(
3✔
890
        &self,
3✔
891
        candidate_inputs: impl IntoIterator<Item = InputPair>,
3✔
892
    ) -> Result<InputPair, SelectionError> {
3✔
893
        self.inner.try_preserving_privacy(candidate_inputs)
3✔
894
    }
3✔
895

896
    /// Contributes the provided list of inputs to the transaction at random indices. If the total input
897
    /// amount exceeds the total output amount after the contribution, adds all excess amount to
898
    /// the receiver change output.
899
    pub fn contribute_inputs(
3✔
900
        self,
3✔
901
        inputs: impl IntoIterator<Item = InputPair>,
3✔
902
    ) -> Result<Self, InputContributionError> {
3✔
903
        let inner = self.state.inner.contribute_inputs(inputs)?;
3✔
904
        Ok(Receiver { state: WantsInputs { inner }, session_context: self.session_context })
3✔
905
    }
3✔
906

907
    /// Commits the inputs as final, and moves on to the next typestate.
908
    ///
909
    /// Inputs cannot be modified after this function is called.
910
    pub fn commit_inputs(self) -> NextStateTransition<SessionEvent, Receiver<WantsFeeRange>> {
6✔
911
        let inner = self.state.inner.clone().commit_inputs();
6✔
912
        NextStateTransition::success(
6✔
913
            SessionEvent::CommittedInputs(inner.receiver_inputs.clone()),
6✔
914
            Receiver { state: WantsFeeRange { inner }, session_context: self.session_context },
6✔
915
        )
916
    }
6✔
917

918
    pub(crate) fn apply_committed_inputs(
3✔
919
        self,
3✔
920
        contributed_inputs: Vec<InputPair>,
3✔
921
    ) -> ReceiveSession {
3✔
922
        let inner = common::WantsFeeRange {
3✔
923
            original_psbt: self.state.inner.original_psbt.clone(),
3✔
924
            payjoin_psbt: self.state.inner.payjoin_psbt.clone(),
3✔
925
            params: self.state.inner.params.clone(),
3✔
926
            change_vout: self.state.inner.change_vout,
3✔
927
            receiver_inputs: contributed_inputs,
3✔
928
        };
3✔
929
        let new_state =
3✔
930
            Receiver { state: WantsFeeRange { inner }, session_context: self.session_context };
3✔
931
        ReceiveSession::WantsFeeRange(new_state)
3✔
932
    }
3✔
933
}
934

935
#[derive(Debug, Clone, PartialEq)]
936
pub struct WantsFeeRange {
937
    inner: common::WantsFeeRange,
938
}
939

940
impl Receiver<WantsFeeRange> {
941
    /// Applies additional fee contribution now that the receiver has contributed inputs
942
    /// and may have added new outputs.
943
    ///
944
    /// How much the receiver ends up paying for fees depends on how much the sender stated they
945
    /// were willing to pay in the parameters of the original proposal. For additional
946
    /// inputs, fees will be subtracted from the sender's outputs as much as possible until we hit
947
    /// the limit the sender specified in the Payjoin parameters. Any remaining fees for the new inputs
948
    /// will be then subtracted from the change output of the receiver.
949
    /// Fees for additional outputs are always subtracted from the receiver's outputs.
950
    ///
951
    /// `max_effective_fee_rate` is the maximum effective fee rate that the receiver is
952
    /// willing to pay for their own input/output contributions. A `max_effective_fee_rate`
953
    /// of zero indicates that the receiver is not willing to pay any additional
954
    /// fees. Errors if the final effective fee rate exceeds `max_effective_fee_rate`.
955
    ///
956
    /// If not provided, `min_fee_rate` and `max_effective_fee_rate` default to the
957
    /// minimum possible relay fee.
958
    ///
959
    /// The minimum effective fee limit is the highest of the minimum limit set by the sender in
960
    /// the original proposal parameters and the limit passed in the `min_fee_rate` parameter.
961
    pub fn apply_fee_range(
6✔
962
        self,
6✔
963
        min_fee_rate: Option<FeeRate>,
6✔
964
        max_effective_fee_rate: Option<FeeRate>,
6✔
965
    ) -> MaybeFatalTransition<SessionEvent, Receiver<ProvisionalProposal>, ProtocolError> {
6✔
966
        let max_effective_fee_rate =
6✔
967
            max_effective_fee_rate.or(Some(self.session_context.max_fee_rate));
6✔
968
        let psbt_context = match self
6✔
969
            .state
6✔
970
            .inner
6✔
971
            .calculate_psbt_context_with_fee_range(min_fee_rate, max_effective_fee_rate)
6✔
972
        {
973
            Ok(inner) => inner,
6✔
974
            Err(e) => {
×
975
                return MaybeFatalTransition::transient(ProtocolError::OriginalPayload(e.into()));
×
976
            }
977
        };
978
        MaybeFatalTransition::success(
6✔
979
            SessionEvent::AppliedFeeRange(psbt_context.clone()),
6✔
980
            Receiver {
6✔
981
                state: ProvisionalProposal { psbt_context },
6✔
982
                session_context: self.session_context,
6✔
983
            },
6✔
984
        )
985
    }
6✔
986

987
    pub(crate) fn apply_applied_fee_range(self, psbt_context: PsbtContext) -> ReceiveSession {
3✔
988
        let new_state = Receiver {
3✔
989
            state: ProvisionalProposal { psbt_context },
3✔
990
            session_context: self.session_context,
3✔
991
        };
3✔
992
        ReceiveSession::ProvisionalProposal(new_state)
3✔
993
    }
3✔
994
}
995

996
#[derive(Debug, Clone, PartialEq)]
997
pub struct ProvisionalProposal {
998
    psbt_context: PsbtContext,
999
}
1000

1001
/// Typestate for a checked proposal which had both the outputs and the inputs modified
1002
/// by the receiver. The receiver may sign and finalize the Payjoin proposal which will be sent to
1003
/// the sender for their signature.
1004
///
1005
/// Call [`Receiver<ProvisionalProposal>::finalize_proposal`] to return a finalized [`PayjoinProposal`].
1006
impl Receiver<ProvisionalProposal> {
1007
    /// Finalizes the Payjoin proposal into a PSBT which the sender will find acceptable before
1008
    /// they re-sign the transaction and broadcast it to the network.
1009
    ///
1010
    /// Finalization consists of two steps:
1011
    ///   1. Remove all sender signatures which were received with the original PSBT as these signatures are now invalid.
1012
    ///   2. Sign and finalize the resulting PSBT using the passed `wallet_process_psbt` signing function.
1013
    pub fn finalize_proposal(
5✔
1014
        self,
5✔
1015
        wallet_process_psbt: impl Fn(&Psbt) -> Result<Psbt, ImplementationError>,
5✔
1016
    ) -> MaybeTransientTransition<SessionEvent, Receiver<PayjoinProposal>, ImplementationError>
5✔
1017
    {
1018
        let original_psbt = self.state.psbt_context.original_psbt.clone();
5✔
1019
        let inner = match self.state.psbt_context.finalize_proposal(wallet_process_psbt) {
5✔
1020
            Ok(inner) => inner,
5✔
1021
            Err(e) => {
×
1022
                return MaybeTransientTransition::transient(e);
×
1023
            }
1024
        };
1025
        let psbt_context = PsbtContext { payjoin_psbt: inner.clone(), original_psbt };
5✔
1026
        let payjoin_proposal = PayjoinProposal { psbt_context: psbt_context.clone() };
5✔
1027
        MaybeTransientTransition::success(
5✔
1028
            SessionEvent::FinalizedProposal(inner),
5✔
1029
            Receiver { state: payjoin_proposal, session_context: self.session_context },
5✔
1030
        )
1031
    }
5✔
1032

1033
    /// The Payjoin proposal PSBT that the receiver needs to sign
1034
    ///
1035
    /// In some applications the entity that progresses the typestate
1036
    /// is different from the entity that has access to the private keys,
1037
    /// so the PSBT to sign must be accessible to such implementers.
1038
    pub fn psbt_to_sign(&self) -> Psbt { self.state.psbt_context.payjoin_psbt.clone() }
1✔
1039

1040
    pub(crate) fn apply_payjoin_proposal(self, payjoin_psbt: Psbt) -> ReceiveSession {
2✔
1041
        let psbt_context = PsbtContext {
2✔
1042
            payjoin_psbt,
2✔
1043
            original_psbt: self.state.psbt_context.original_psbt.clone(),
2✔
1044
        };
2✔
1045
        let new_state = Receiver {
2✔
1046
            state: PayjoinProposal { psbt_context },
2✔
1047
            session_context: self.session_context,
2✔
1048
        };
2✔
1049
        ReceiveSession::PayjoinProposal(new_state)
2✔
1050
    }
2✔
1051
}
1052

1053
#[derive(Debug, Clone, PartialEq)]
1054
pub struct PayjoinProposal {
1055
    psbt_context: PsbtContext,
1056
}
1057

1058
/// A finalized Payjoin proposal, complete with fees and receiver signatures, that the sender
1059
/// should find acceptable.
1060
impl Receiver<PayjoinProposal> {
1061
    /// The UTXOs that would be spent by this Payjoin transaction.
1062
    pub fn utxos_to_be_locked(&self) -> impl '_ + Iterator<Item = &bitcoin::OutPoint> {
×
1063
        // TODO: de-duplicate this with the v1 implementation
1064
        // It would make more sense if the payjoin proposal was only available after utxos are locked via session persister
1065
        self.psbt_context.payjoin_psbt.unsigned_tx.input.iter().map(|input| &input.previous_output)
×
1066
    }
×
1067

1068
    /// The Payjoin Proposal PSBT.
1069
    pub fn psbt(&self) -> &Psbt { &self.psbt_context.payjoin_psbt }
6✔
1070

1071
    /// Construct an OHTTP Encapsulated HTTP POST request for the Proposal PSBT
1072
    pub fn create_post_request(
3✔
1073
        &self,
3✔
1074
        ohttp_relay: impl IntoUrl,
3✔
1075
    ) -> Result<(Request, ohttp::ClientResponse), Error> {
3✔
1076
        let target_resource: Url;
1077
        let body: Vec<u8>;
1078
        let method: &str;
1079

1080
        if let Some(e) = &self.session_context.reply_key {
3✔
1081
            // Prepare v2 payload
1082
            let payjoin_bytes = self.psbt().serialize();
2✔
1083
            let sender_mailbox = short_id_from_pubkey(e);
2✔
1084
            target_resource = mailbox_endpoint(&self.session_context.directory, &sender_mailbox);
2✔
1085
            body = encrypt_message_b(payjoin_bytes, &self.session_context.receiver_key, e)?;
2✔
1086
            method = "POST";
2✔
1087
        } else {
1✔
1088
            // Prepare v2 wrapped and backwards-compatible v1 payload
1✔
1089
            body = self.psbt().to_string().as_bytes().to_vec();
1✔
1090
            let receiver_mailbox =
1✔
1091
                short_id_from_pubkey(self.session_context.receiver_key.public_key());
1✔
1092
            target_resource = mailbox_endpoint(&self.session_context.directory, &receiver_mailbox);
1✔
1093
            method = "PUT";
1✔
1094
        }
1✔
1095
        tracing::trace!("Payjoin PSBT target: {}", target_resource.as_str());
3✔
1096
        let (body, ctx) = ohttp_encapsulate(
3✔
1097
            &self.session_context.ohttp_keys,
3✔
1098
            method,
3✔
1099
            target_resource.as_str(),
3✔
1100
            Some(&body),
3✔
1101
        )?;
×
1102

1103
        let req = Request::new_v2(&self.session_context.full_relay_url(ohttp_relay)?, &body);
3✔
1104
        Ok((req, ctx))
3✔
1105
    }
3✔
1106

1107
    /// Processes the response for the final POST message from the receiver client in the v2 Payjoin protocol.
1108
    ///
1109
    /// This function decapsulates the response using the provided OHTTP context. If the response status is successful,
1110
    /// it indicates that the Payjoin proposal has been accepted. Otherwise, it returns an error with the status code.
1111
    ///
1112
    /// After this function is called, the receiver can either wait for the Payjoin transaction to be broadcast or
1113
    /// choose to broadcast the original PSBT.
1114
    pub fn process_response(
3✔
1115
        self,
3✔
1116
        res: &[u8],
3✔
1117
        ohttp_context: ohttp::ClientResponse,
3✔
1118
    ) -> MaybeFatalTransition<SessionEvent, Receiver<Monitor>, ProtocolError> {
3✔
1119
        match process_post_res(res, ohttp_context) {
3✔
1120
            Ok(_) => MaybeFatalTransition::success(
3✔
1121
                SessionEvent::PostedPayjoinProposal(),
3✔
1122
                Receiver {
3✔
1123
                    state: Monitor { psbt_context: self.state.psbt_context.clone() },
3✔
1124
                    session_context: self.session_context.clone(),
3✔
1125
                },
3✔
1126
            ),
1127
            Err(e) =>
×
1128
                if e.is_fatal() {
×
1129
                    MaybeFatalTransition::fatal(
×
1130
                        SessionEvent::Closed(SessionOutcome::Failure),
×
1131
                        ProtocolError::V2(InternalSessionError::DirectoryResponse(e).into()),
×
1132
                    )
1133
                } else {
1134
                    MaybeFatalTransition::transient(ProtocolError::V2(
×
1135
                        InternalSessionError::DirectoryResponse(e).into(),
×
1136
                    ))
×
1137
                },
1138
        }
1139
    }
3✔
1140

1141
    pub(crate) fn apply_payjoin_posted(self) -> ReceiveSession {
1✔
1142
        ReceiveSession::Monitor(Receiver {
1✔
1143
            state: Monitor { psbt_context: self.state.psbt_context.clone() },
1✔
1144
            session_context: self.session_context,
1✔
1145
        })
1✔
1146
    }
1✔
1147
}
1148

1149
#[derive(Debug, Clone, PartialEq)]
1150
pub struct HasReplyableError {
1151
    error_reply: JsonReply,
1152
}
1153

1154
impl Receiver<HasReplyableError> {
1155
    /// Construct an OHTTP Encapsulated HTTP POST request to return
1156
    /// a Receiver Error Response
1157
    pub fn create_error_request(
4✔
1158
        &self,
4✔
1159
        ohttp_relay: impl IntoUrl,
4✔
1160
    ) -> Result<(Request, ohttp::ClientResponse), SessionError> {
4✔
1161
        let session_context = &self.session_context;
4✔
1162
        if session_context.expiration.elapsed() {
4✔
1163
            return Err(InternalSessionError::Expired(session_context.expiration).into());
1✔
1164
        }
3✔
1165
        let mailbox =
3✔
1166
            mailbox_endpoint(&session_context.directory, &session_context.reply_mailbox_id());
3✔
1167
        let body = {
3✔
1168
            if let Some(reply_key) = &session_context.reply_key {
3✔
1169
                encrypt_message_b(
1✔
1170
                    self.error_reply.to_json().to_string().as_bytes().to_vec(),
1✔
1171
                    &session_context.receiver_key,
1✔
1172
                    reply_key,
1✔
1173
                )
1174
                .map_err(InternalSessionError::Hpke)?
1✔
1175
            } else {
1176
                // Post a generic unavailable error message in the case where we don't have a reply key
1177
                let err =
2✔
1178
                    JsonReply::new(crate::error_codes::ErrorCode::Unavailable, "Receiver error");
2✔
1179
                err.to_json().to_string().as_bytes().to_vec()
2✔
1180
            }
1181
        };
1182
        let (body, ohttp_ctx) =
3✔
1183
            ohttp_encapsulate(&session_context.ohttp_keys.0, "POST", mailbox.as_str(), Some(&body))
3✔
1184
                .map_err(InternalSessionError::OhttpEncapsulation)?;
3✔
1185
        let req = Request::new_v2(&session_context.full_relay_url(ohttp_relay)?, &body);
3✔
1186
        Ok((req, ohttp_ctx))
3✔
1187
    }
4✔
1188

1189
    /// Process an OHTTP Encapsulated HTTP POST Error response
1190
    /// to ensure it has been posted properly
1191
    pub fn process_error_response(
1✔
1192
        &self,
1✔
1193
        res: &[u8],
1✔
1194
        ohttp_context: ohttp::ClientResponse,
1✔
1195
    ) -> MaybeSuccessTransition<SessionEvent, (), ProtocolError> {
1✔
1196
        match process_post_res(res, ohttp_context) {
1✔
1197
            Ok(_) =>
1198
                MaybeSuccessTransition::success(SessionEvent::Closed(SessionOutcome::Failure), ()),
1✔
1199
            Err(e) =>
×
1200
                if e.is_fatal() {
×
1201
                    MaybeSuccessTransition::fatal(
×
1202
                        SessionEvent::Closed(SessionOutcome::Failure),
×
1203
                        ProtocolError::V2(InternalSessionError::DirectoryResponse(e).into()),
×
1204
                    )
1205
                } else {
1206
                    MaybeSuccessTransition::transient(ProtocolError::V2(
×
1207
                        InternalSessionError::DirectoryResponse(e).into(),
×
1208
                    ))
×
1209
                },
1210
        }
1211
    }
1✔
1212
}
1213

1214
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1215
pub struct Monitor {
1216
    psbt_context: PsbtContext,
1217
}
1218

1219
impl Receiver<Monitor> {
1220
    pub fn check_payment(
7✔
1221
        &self,
7✔
1222
        transaction_exists: impl Fn(Txid) -> Result<Option<bitcoin::Transaction>, ImplementationError>,
7✔
1223
        outpoint_spent: impl Fn(OutPoint) -> Result<bool, ImplementationError>,
7✔
1224
    ) -> MaybeFatalOrSuccessTransition<SessionEvent, Self, Error> {
7✔
1225
        let payjoin_proposal = &self.state.psbt_context.payjoin_psbt;
7✔
1226
        let payjoin_txid = payjoin_proposal.unsigned_tx.compute_txid();
7✔
1227
        // If we have a payjoin transaction with segwit inputs, we can check for the txid
1228
        match transaction_exists(payjoin_txid) {
7✔
1229
            Ok(Some(tx)) => {
3✔
1230
                let tx_id = tx.compute_txid();
3✔
1231
                if tx_id != payjoin_txid {
3✔
1232
                    return MaybeFatalOrSuccessTransition::transient(Error::Implementation(
×
1233
                        ImplementationError::from(format!("Payjoin transaction ID mismatch. Expected: {payjoin_txid}, Got: {tx_id}").as_str()),
×
1234
                    ));
×
1235
                }
3✔
1236
                // TODO: should we check for witness and scriptsig on the tx?
1237
                let mut sender_witnesses = vec![];
3✔
1238

1239
                for i in self.state.psbt_context.sender_input_indexes() {
3✔
1240
                    let input =
3✔
1241
                        tx.input.get(i).expect("sender_input_indexes should return valid indices");
3✔
1242
                    sender_witnesses.push((input.script_sig.clone(), input.witness.clone()));
3✔
1243
                }
3✔
1244
                // Payjoin transaction with segwit inputs was detected. Log the signatures and complete the session
1245
                return MaybeFatalOrSuccessTransition::success(SessionEvent::Closed(
3✔
1246
                    SessionOutcome::Success(sender_witnesses),
3✔
1247
                ));
3✔
1248
            }
1249
            Ok(None) => {}
4✔
1250
            Err(e) => return MaybeFatalOrSuccessTransition::transient(Error::Implementation(e)),
×
1251
        }
1252

1253
        // Check for fallback being broadcasted
1254
        let fallback_tx = self
4✔
1255
            .state
4✔
1256
            .psbt_context
4✔
1257
            .original_psbt
4✔
1258
            .clone()
4✔
1259
            .extract_tx_fee_rate_limit()
4✔
1260
            .expect("Checked in earlier typestates");
4✔
1261
        match transaction_exists(fallback_tx.compute_txid()) {
4✔
1262
            Ok(Some(_)) =>
1263
                return MaybeFatalOrSuccessTransition::success(SessionEvent::Closed(
1✔
1264
                    SessionOutcome::FallbackBroadcasted,
1✔
1265
                )),
1✔
1266
            Ok(None) => {}
3✔
1267
            Err(e) => return MaybeFatalOrSuccessTransition::transient(Error::Implementation(e)),
×
1268
        }
1269

1270
        let mut outpoints_spend = 0;
3✔
1271
        for ot in payjoin_proposal.unsigned_tx.input.iter() {
6✔
1272
            match outpoint_spent(ot.previous_output) {
6✔
1273
                Ok(false) => {}
3✔
1274
                Ok(true) => outpoints_spend += 1,
3✔
UNCOV
1275
                Err(e) =>
×
UNCOV
1276
                    return MaybeFatalOrSuccessTransition::transient(Error::Implementation(e)),
×
1277
            }
1278
        }
1279

1280
        if outpoints_spend == payjoin_proposal.unsigned_tx.input.len() {
3✔
1281
            // All the payjoin proposal outpoints were spent. This means our payjoin proposal has non-segwit inputs and is broadcasted.
1282
            return MaybeFatalOrSuccessTransition::success(SessionEvent::Closed(
1✔
1283
                // TODO: there seems to be not great way to get the tx of the tx that spent these outpoints.
1✔
1284
                SessionOutcome::Success(vec![]),
1✔
1285
            ));
1✔
1286
        } else if outpoints_spend > 0 {
2✔
1287
            // Some outpoints were spent but not in the payjoin proposal. This is a double spend.
1288
            return MaybeFatalOrSuccessTransition::success(SessionEvent::Closed(
1✔
1289
                SessionOutcome::Failure,
1✔
1290
            ));
1✔
1291
        }
1✔
1292

1293
        MaybeFatalOrSuccessTransition::no_results(self.clone())
1✔
1294
    }
7✔
1295
}
1296

1297
/// Derive a mailbox endpoint on a directory given a [`ShortId`].
1298
/// It consists of a directory URL and the session ShortID in the path.
1299
fn mailbox_endpoint(directory: &Url, id: &ShortId) -> Url {
14✔
1300
    let mut url = directory.clone();
14✔
1301
    {
14✔
1302
        let mut path_segments =
14✔
1303
            url.path_segments_mut().expect("Payjoin Directory URL cannot be a base");
14✔
1304
        path_segments.push(&id.to_string());
14✔
1305
    }
14✔
1306
    url
14✔
1307
}
14✔
1308

1309
/// Gets the Payjoin URI from a session context
1310
pub(crate) fn pj_uri<'a>(
11✔
1311
    session_context: &SessionContext,
11✔
1312
    output_substitution: OutputSubstitution,
11✔
1313
) -> crate::PjUri<'a> {
11✔
1314
    use crate::uri::PayjoinExtras;
1315
    let pj_param = crate::uri::PjParam::V2(crate::uri::v2::PjParam::new(
11✔
1316
        session_context.directory.clone(),
11✔
1317
        session_context.proposal_mailbox_id(),
11✔
1318
        session_context.expiration,
11✔
1319
        session_context.ohttp_keys.clone(),
11✔
1320
        session_context.receiver_key.public_key().clone(),
11✔
1321
    ));
11✔
1322
    let extras = PayjoinExtras { pj_param, output_substitution };
11✔
1323
    let mut uri = bitcoin_uri::Uri::with_extras(session_context.address.clone(), extras);
11✔
1324
    uri.amount = session_context.amount;
11✔
1325

1326
    uri
11✔
1327
}
11✔
1328

1329
#[cfg(test)]
1330
pub mod test {
1331
    use std::str::FromStr;
1332

1333
    use bitcoin::FeeRate;
1334
    use once_cell::sync::Lazy;
1335
    use payjoin_test_utils::{
1336
        BoxError, EXAMPLE_URL, KEM, KEY_ID, ORIGINAL_PSBT, PARSED_ORIGINAL_PSBT,
1337
        PARSED_PAYJOIN_PROPOSAL, QUERY_PARAMS, SYMMETRIC,
1338
    };
1339

1340
    use super::*;
1341
    use crate::output_substitution::OutputSubstitution;
1342
    use crate::persist::test_utils::InMemoryTestPersister;
1343
    use crate::persist::{
1344
        NoopSessionPersister, OptionalTransitionOutcome, RejectTransient, Rejection,
1345
    };
1346
    use crate::receive::optional_parameters::Params;
1347
    use crate::receive::v2;
1348
    use crate::ImplementationError;
1349

1350
    pub(crate) static SHARED_CONTEXT: Lazy<SessionContext> = Lazy::new(|| SessionContext {
1351
        address: Address::from_str("tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4")
1✔
1352
            .expect("valid address")
1✔
1353
            .assume_checked(),
1✔
1354
        directory: Url::from_str(EXAMPLE_URL).expect("Could not parse Url"),
1✔
1355
        ohttp_keys: OhttpKeys(
1✔
1356
            ohttp::KeyConfig::new(KEY_ID, KEM, Vec::from(SYMMETRIC)).expect("valid key config"),
1✔
1357
        ),
1✔
1358
        expiration: Time::from_now(Duration::from_secs(60)).expect("Valid timestamp"),
1✔
1359
        receiver_key: HpkeKeyPair::gen_keypair(),
1✔
1360
        reply_key: None,
1✔
1361
        amount: None,
1✔
1362
        max_fee_rate: FeeRate::BROADCAST_MIN,
1363
    });
1✔
1364

1365
    pub(crate) fn unchecked_proposal_v2_from_test_vector() -> UncheckedOriginalPayload {
8✔
1366
        let pairs = url::form_urlencoded::parse(QUERY_PARAMS.as_bytes());
8✔
1367
        let params = Params::from_query_pairs(pairs, &[Version::Two])
8✔
1368
            .expect("Test utils query params should not fail");
8✔
1369
        UncheckedOriginalPayload {
8✔
1370
            original: OriginalPayload { psbt: PARSED_ORIGINAL_PSBT.clone(), params },
8✔
1371
        }
8✔
1372
    }
8✔
1373

1374
    pub(crate) fn maybe_inputs_owned_v2_from_test_vector() -> MaybeInputsOwned {
1✔
1375
        let pairs = url::form_urlencoded::parse(QUERY_PARAMS.as_bytes());
1✔
1376
        let params = Params::from_query_pairs(pairs, &[Version::Two])
1✔
1377
            .expect("Test utils query params should not fail");
1✔
1378
        MaybeInputsOwned {
1✔
1379
            original: OriginalPayload { psbt: PARSED_ORIGINAL_PSBT.clone(), params },
1✔
1380
        }
1✔
1381
    }
1✔
1382

1383
    pub(crate) fn mock_err() -> JsonReply {
3✔
1384
        let noop_persister = NoopSessionPersister::default();
3✔
1385
        let receiver = Receiver {
3✔
1386
            state: unchecked_proposal_v2_from_test_vector(),
3✔
1387
            session_context: SHARED_CONTEXT.clone(),
3✔
1388
        };
3✔
1389
        let server_error = || {
3✔
1390
            receiver
3✔
1391
                .clone()
3✔
1392
                .check_broadcast_suitability(None, |_| Err("mock error".into()))
3✔
1393
                .save(&noop_persister)
3✔
1394
        };
3✔
1395

1396
        let error = server_error().expect_err("Server error should be populated with mock error");
3✔
1397
        let res = error.api_error().expect("check_broadcast error should propagate to api error");
3✔
1398
        JsonReply::from(&res)
3✔
1399
    }
3✔
1400

1401
    #[test]
1402
    fn test_monitor_typestate() -> Result<(), BoxError> {
1✔
1403
        let psbt_ctx = PsbtContext {
1✔
1404
            original_psbt: PARSED_ORIGINAL_PSBT.clone(),
1✔
1405
            payjoin_psbt: PARSED_PAYJOIN_PROPOSAL.clone(),
1✔
1406
        };
1✔
1407
        let monitor = Receiver {
1✔
1408
            state: Monitor { psbt_context: psbt_ctx },
1✔
1409
            session_context: SHARED_CONTEXT.clone(),
1✔
1410
        };
1✔
1411

1412
        let payjoin_tx = PARSED_PAYJOIN_PROPOSAL.clone().unsigned_tx;
1✔
1413
        let original_tx = PARSED_ORIGINAL_PSBT.clone().extract_tx().expect("valid tx");
1✔
1414

1415
        // Nothing was spent, should be in the same state
1416
        let persister = InMemoryTestPersister::default();
1✔
1417
        let res = monitor
1✔
1418
            .check_payment(|_| Ok(None), |_| Ok(false))
2✔
1419
            .save(&persister)
1✔
1420
            .expect("InMemoryTestPersister shouldn't fail");
1✔
1421
        assert!(matches!(res, OptionalTransitionOutcome::Stasis(_)));
1✔
1422
        assert!(!persister.inner.read().expect("Shouldn't be poisoned").is_closed);
1✔
1423
        assert_eq!(persister.inner.read().expect("Shouldn't be poisoned").events.len(), 0);
1✔
1424

1425
        // Payjoin was broadcasted, should progress to success
1426
        let persister = InMemoryTestPersister::default();
1✔
1427
        let res = monitor
1✔
1428
            .check_payment(|_| Ok(Some(payjoin_tx.clone())), |_| Ok(false))
1✔
1429
            .save(&persister)
1✔
1430
            .expect("InMemoryTestPersister shouldn't fail");
1✔
1431

1432
        assert!(matches!(res, OptionalTransitionOutcome::Progress(_)));
1✔
1433
        assert!(persister.inner.read().expect("Shouldn't be poisoned").is_closed);
1✔
1434
        assert_eq!(persister.inner.read().expect("Shouldn't be poisoned").events.len(), 1);
1✔
1435
        // TODO: check for exact events
1436

1437
        // fallback was broadcasted, should progress to success
1438
        let persister = InMemoryTestPersister::default();
1✔
1439
        let res = monitor
1✔
1440
            .check_payment(
1✔
1441
                |txid| {
2✔
1442
                    // Emulate if one of the fallback outpoints was double spent
1443
                    if txid == original_tx.compute_txid() {
2✔
1444
                        Ok(Some(original_tx.clone()))
1✔
1445
                    } else {
1446
                        Ok(None)
1✔
1447
                    }
1448
                },
2✔
1449
                |_| Ok(false),
1450
            )
1451
            .save(&persister)
1✔
1452
            .expect("InMemoryTestPersister shouldn't fail");
1✔
1453

1454
        assert!(matches!(res, OptionalTransitionOutcome::Progress(_)));
1✔
1455
        assert!(persister.inner.read().expect("Shouldn't be poisoned").is_closed);
1✔
1456
        assert_eq!(
1✔
1457
            persister.inner.read().expect("Shouldn't be poisoned").events.last(),
1✔
1458
            Some(&SessionEvent::Closed(SessionOutcome::FallbackBroadcasted))
1459
        );
1460

1461
        let persister = InMemoryTestPersister::default();
1✔
1462
        let res = monitor
1✔
1463
            .check_payment(|_| Ok(None), |_| Ok(true))
2✔
1464
            .save(&persister)
1✔
1465
            .expect("InMemoryTestPersister shouldn't fail");
1✔
1466

1467
        assert!(matches!(res, OptionalTransitionOutcome::Progress(_)));
1✔
1468
        assert!(persister.inner.read().expect("Shouldn't be poisoned").is_closed);
1✔
1469
        assert_eq!(persister.inner.read().expect("Shouldn't be poisoned").events.len(), 1);
1✔
1470

1471
        let persister = InMemoryTestPersister::default();
1✔
1472
        monitor
1✔
1473
            .check_payment(
1✔
1474
                |_| Ok(None),
2✔
1475
                |outpoint| {
2✔
1476
                    if outpoint == payjoin_tx.input[0].previous_output {
2✔
1477
                        Ok(true)
1✔
1478
                    } else {
1479
                        Ok(false)
1✔
1480
                    }
1481
                },
2✔
1482
            )
1483
            .save(&persister)
1✔
1484
            .expect("InMemoryTestPersister shouldn't fail");
1✔
1485

1486
        assert!(persister.inner.read().expect("Shouldn't be poisoned").is_closed);
1✔
1487
        assert_eq!(persister.inner.read().expect("Shouldn't be poisoned").events.len(), 1);
1✔
1488
        assert_eq!(
1✔
1489
            persister.inner.read().expect("Shouldn't be poisoned").events.last(),
1✔
1490
            Some(&SessionEvent::Closed(SessionOutcome::Failure))
1491
        );
1492

1493
        // assert_eq!(
1494
        //     err.to_string(),
1495
        //     Error::Protocol(ProtocolError::V2(
1496
        //         InternalSessionError::FallbackOutpointsSpent(vec![
1497
        //             payjoin_tx.input[0].previous_output
1498
        //         ],)
1499
        //         .into()
1500
        //     ))
1501
        //     .to_string()
1502
        // );
1503

1504
        Ok(())
1✔
1505
    }
1✔
1506

1507
    #[test]
1508
    fn test_v2_mutable_receiver_state_closures() {
1✔
1509
        let persister = NoopSessionPersister::default();
1✔
1510
        let mut call_count = 0;
1✔
1511
        let maybe_inputs_owned = maybe_inputs_owned_v2_from_test_vector();
1✔
1512
        let receiver =
1✔
1513
            v2::Receiver { state: maybe_inputs_owned, session_context: SHARED_CONTEXT.clone() };
1✔
1514

1515
        fn mock_callback(call_count: &mut usize, ret: bool) -> Result<bool, ImplementationError> {
4✔
1516
            *call_count += 1;
4✔
1517
            Ok(ret)
4✔
1518
        }
4✔
1519

1520
        let maybe_inputs_seen =
1✔
1521
            receiver.check_inputs_not_owned(&mut |_| mock_callback(&mut call_count, false));
1✔
1522
        assert_eq!(call_count, 1);
1✔
1523

1524
        let outputs_unknown = maybe_inputs_seen
1✔
1525
            .save(&persister)
1✔
1526
            .expect("Noop persister shouldn't fail")
1✔
1527
            .check_no_inputs_seen_before(&mut |_| mock_callback(&mut call_count, false))
1✔
1528
            .save(&persister)
1✔
1529
            .expect("Noop persister shouldn't fail");
1✔
1530
        assert_eq!(call_count, 2);
1✔
1531

1532
        let _wants_outputs = outputs_unknown
1✔
1533
            .identify_receiver_outputs(&mut |_| mock_callback(&mut call_count, true));
2✔
1534
        // there are 2 receiver outputs so we should expect this callback to run twice incrementing
1535
        // call count twice
1536
        assert_eq!(call_count, 4);
1✔
1537
    }
1✔
1538

1539
    #[test]
1540
    fn test_unchecked_proposal_transient_error() -> Result<(), BoxError> {
1✔
1541
        let unchecked_proposal = unchecked_proposal_v2_from_test_vector();
1✔
1542
        let receiver =
1✔
1543
            v2::Receiver { state: unchecked_proposal, session_context: SHARED_CONTEXT.clone() };
1✔
1544

1545
        let unchecked_proposal = receiver.check_broadcast_suitability(Some(FeeRate::MIN), |_| {
1✔
1546
            Err(ImplementationError::new(Error::Implementation("mock error".into())))
1✔
1547
        });
1✔
1548

1549
        match unchecked_proposal {
1✔
1550
            MaybeFatalTransition(Err(Rejection::Transient(RejectTransient(
1551
                Error::Implementation(error),
1✔
1552
            )))) => assert_eq!(
1✔
1553
                error.to_string(),
1✔
1554
                Error::Implementation("mock error".into()).to_string()
1✔
1555
            ),
1556
            _ => panic!("Expected Implementation error"),
×
1557
        }
1558

1559
        Ok(())
1✔
1560
    }
1✔
1561

1562
    #[test]
1563
    fn test_unchecked_proposal_fatal_error() -> Result<(), BoxError> {
1✔
1564
        let persister = NoopSessionPersister::default();
1✔
1565
        let unchecked_proposal = unchecked_proposal_v2_from_test_vector();
1✔
1566
        let receiver =
1✔
1567
            v2::Receiver { state: unchecked_proposal, session_context: SHARED_CONTEXT.clone() };
1✔
1568

1569
        let unchecked_proposal_err = receiver
1✔
1570
            .check_broadcast_suitability(Some(FeeRate::MIN), |_| Ok(false))
1✔
1571
            .save(&persister)
1✔
1572
            .expect_err("should have replyable error");
1✔
1573
        let has_error = unchecked_proposal_err.error_state().expect("should have state");
1✔
1574

1575
        let _err_req = has_error.create_error_request(EXAMPLE_URL)?;
1✔
1576
        Ok(())
1✔
1577
    }
1✔
1578

1579
    #[test]
1580
    fn test_maybe_inputs_seen_transient_error() -> Result<(), BoxError> {
1✔
1581
        let persister = NoopSessionPersister::default();
1✔
1582
        let unchecked_proposal = unchecked_proposal_v2_from_test_vector();
1✔
1583
        let receiver =
1✔
1584
            v2::Receiver { state: unchecked_proposal, session_context: SHARED_CONTEXT.clone() };
1✔
1585

1586
        let maybe_inputs_owned = receiver
1✔
1587
            .assume_interactive_receiver()
1✔
1588
            .save(&persister)
1✔
1589
            .expect("Noop persister shouldn't fail");
1✔
1590
        let maybe_inputs_seen = maybe_inputs_owned.check_inputs_not_owned(&mut |_| {
1✔
1591
            Err(ImplementationError::new(Error::Implementation("mock error".into())))
1✔
1592
        });
1✔
1593

1594
        match maybe_inputs_seen {
1✔
1595
            MaybeFatalTransition(Err(Rejection::Transient(RejectTransient(
1596
                Error::Implementation(error),
1✔
1597
            )))) => assert_eq!(
1✔
1598
                error.to_string(),
1✔
1599
                Error::Implementation("mock error".into()).to_string()
1✔
1600
            ),
1601
            _ => panic!("Expected Implementation error"),
×
1602
        }
1603

1604
        Ok(())
1✔
1605
    }
1✔
1606

1607
    #[test]
1608
    fn test_outputs_unknown_transient_error() -> Result<(), BoxError> {
1✔
1609
        let persister = NoopSessionPersister::default();
1✔
1610
        let unchecked_proposal = unchecked_proposal_v2_from_test_vector();
1✔
1611
        let receiver =
1✔
1612
            v2::Receiver { state: unchecked_proposal, session_context: SHARED_CONTEXT.clone() };
1✔
1613

1614
        let maybe_inputs_owned = receiver
1✔
1615
            .assume_interactive_receiver()
1✔
1616
            .save(&persister)
1✔
1617
            .expect("Noop persister shouldn't fail");
1✔
1618
        let maybe_inputs_seen = maybe_inputs_owned
1✔
1619
            .check_inputs_not_owned(&mut |_| Ok(false))
1✔
1620
            .save(&persister)
1✔
1621
            .expect("Noop persister shouldn't fail");
1✔
1622
        let outputs_unknown = maybe_inputs_seen.check_no_inputs_seen_before(&mut |_| {
1✔
1623
            Err(ImplementationError::new(Error::Implementation("mock error".into())))
1✔
1624
        });
1✔
1625
        match outputs_unknown {
1✔
1626
            MaybeFatalTransition(Err(Rejection::Transient(RejectTransient(
1627
                Error::Implementation(error),
1✔
1628
            )))) => assert_eq!(
1✔
1629
                error.to_string(),
1✔
1630
                Error::Implementation("mock error".into()).to_string()
1✔
1631
            ),
1632
            _ => panic!("Expected Implementation error"),
×
1633
        }
1634

1635
        Ok(())
1✔
1636
    }
1✔
1637

1638
    #[test]
1639
    fn test_wants_outputs_transient_error() -> Result<(), BoxError> {
1✔
1640
        let persister = NoopSessionPersister::default();
1✔
1641
        let unchecked_proposal = unchecked_proposal_v2_from_test_vector();
1✔
1642
        let receiver =
1✔
1643
            v2::Receiver { state: unchecked_proposal, session_context: SHARED_CONTEXT.clone() };
1✔
1644

1645
        let maybe_inputs_owned = receiver
1✔
1646
            .assume_interactive_receiver()
1✔
1647
            .save(&persister)
1✔
1648
            .expect("Noop persister shouldn't fail");
1✔
1649
        let maybe_inputs_seen = maybe_inputs_owned
1✔
1650
            .check_inputs_not_owned(&mut |_| Ok(false))
1✔
1651
            .save(&persister)
1✔
1652
            .expect("Noop persister should not fail");
1✔
1653
        let outputs_unknown = maybe_inputs_seen
1✔
1654
            .check_no_inputs_seen_before(&mut |_| Ok(false))
1✔
1655
            .save(&persister)
1✔
1656
            .expect("Noop persister should not fail");
1✔
1657
        let wants_outputs = outputs_unknown.identify_receiver_outputs(&mut |_| {
1✔
1658
            Err(ImplementationError::new(Error::Implementation("mock error".into())))
1✔
1659
        });
1✔
1660
        match wants_outputs {
1✔
1661
            MaybeFatalTransition(Err(Rejection::Transient(RejectTransient(
1662
                Error::Implementation(error),
1✔
1663
            )))) => assert_eq!(
1✔
1664
                error.to_string(),
1✔
1665
                Error::Implementation("mock error".into()).to_string()
1✔
1666
            ),
1667
            _ => panic!("Expected Implementation error"),
×
1668
        }
1669

1670
        Ok(())
1✔
1671
    }
1✔
1672

1673
    #[test]
1674
    fn test_create_error_request() -> Result<(), BoxError> {
1✔
1675
        let mock_err = mock_err();
1✔
1676
        let expected_json = serde_json::json!({
1✔
1677
            "errorCode": "unavailable",
1✔
1678
            "message": "Receiver error"
1✔
1679
        });
1680

1681
        assert_eq!(mock_err.to_json(), expected_json);
1✔
1682

1683
        let receiver = Receiver {
1✔
1684
            state: HasReplyableError { error_reply: mock_err.clone() },
1✔
1685
            session_context: SHARED_CONTEXT.clone(),
1✔
1686
        };
1✔
1687

1688
        let (_req, _ctx) = receiver.create_error_request(EXAMPLE_URL)?;
1✔
1689

1690
        Ok(())
1✔
1691
    }
1✔
1692

1693
    #[test]
1694
    fn test_create_error_request_expiration() -> Result<(), BoxError> {
1✔
1695
        let now = crate::time::Time::now();
1✔
1696
        let context = SessionContext { expiration: now, ..SHARED_CONTEXT.clone() };
1✔
1697
        let receiver = Receiver {
1✔
1698
            state: HasReplyableError { error_reply: mock_err() },
1✔
1699
            session_context: context.clone(),
1✔
1700
        };
1✔
1701

1702
        let expiration = receiver.create_error_request(EXAMPLE_URL);
1✔
1703

1704
        match expiration {
1✔
1705
            Err(error) => assert_eq!(
1✔
1706
                error.to_string(),
1✔
1707
                SessionError::from(InternalSessionError::Expired(now)).to_string()
1✔
1708
            ),
1709
            Ok(_) => panic!("Expected session expiration error, got success"),
×
1710
        }
1711
        Ok(())
1✔
1712
    }
1✔
1713

1714
    #[test]
1715
    fn default_max_fee_rate() {
1✔
1716
        let noop_persister = NoopSessionPersister::default();
1✔
1717
        let receiver = ReceiverBuilder::new(
1✔
1718
            SHARED_CONTEXT.address.clone(),
1✔
1719
            SHARED_CONTEXT.directory.as_str(),
1✔
1720
            SHARED_CONTEXT.ohttp_keys.clone(),
1✔
1721
        )
1722
        .expect("constructor on test vector should not fail")
1✔
1723
        .build()
1✔
1724
        .save(&noop_persister)
1✔
1725
        .expect("Noop persister shouldn't fail");
1✔
1726

1727
        assert_eq!(receiver.session_context.max_fee_rate, FeeRate::BROADCAST_MIN);
1✔
1728

1729
        let non_default_max_fee_rate =
1✔
1730
            FeeRate::from_sat_per_vb(1000).expect("Fee rate should be valid");
1✔
1731
        let receiver = ReceiverBuilder::new(
1✔
1732
            SHARED_CONTEXT.address.clone(),
1✔
1733
            SHARED_CONTEXT.directory.as_str(),
1✔
1734
            SHARED_CONTEXT.ohttp_keys.clone(),
1✔
1735
        )
1736
        .expect("constructor on test vector should not fail")
1✔
1737
        .with_max_fee_rate(non_default_max_fee_rate)
1✔
1738
        .build()
1✔
1739
        .save(&noop_persister)
1✔
1740
        .expect("Noop persister shouldn't fail");
1✔
1741
        assert_eq!(receiver.session_context.max_fee_rate, non_default_max_fee_rate);
1✔
1742
    }
1✔
1743

1744
    #[test]
1745
    fn default_expiration() {
1✔
1746
        let noop_persister = NoopSessionPersister::default();
1✔
1747

1748
        let with_default_expiration = ReceiverBuilder::new(
1✔
1749
            SHARED_CONTEXT.address.clone(),
1✔
1750
            SHARED_CONTEXT.directory.as_str(),
1✔
1751
            SHARED_CONTEXT.ohttp_keys.clone(),
1✔
1752
        )
1753
        .expect("constructor on test vector should not fail")
1✔
1754
        .build()
1✔
1755
        .save(&noop_persister)
1✔
1756
        .expect("Noop persister shouldn't fail");
1✔
1757

1758
        let short_expiration = Duration::from_secs(60);
1✔
1759
        let with_short_expiration = ReceiverBuilder::new(
1✔
1760
            SHARED_CONTEXT.address.clone(),
1✔
1761
            SHARED_CONTEXT.directory.as_str(),
1✔
1762
            SHARED_CONTEXT.ohttp_keys.clone(),
1✔
1763
        )
1764
        .expect("constructor on test vector should not fail")
1✔
1765
        .with_expiration(short_expiration)
1✔
1766
        .build()
1✔
1767
        .save(&noop_persister)
1✔
1768
        .expect("Noop persister shouldn't fail");
1✔
1769

1770
        assert_ne!(
1✔
1771
            with_short_expiration.session_context.expiration,
1772
            with_default_expiration.session_context.expiration
1773
        );
1774
        assert!(
1✔
1775
            with_short_expiration.session_context.expiration
1✔
1776
                < with_default_expiration.session_context.expiration
1✔
1777
        );
1778
    }
1✔
1779

1780
    #[test]
1781
    fn test_v2_pj_uri() {
1✔
1782
        let uri =
1✔
1783
            Receiver { state: Initialized {}, session_context: SHARED_CONTEXT.clone() }.pj_uri();
1✔
1784
        assert_ne!(uri.extras.pj_param.endpoint().as_str(), EXAMPLE_URL);
1✔
1785
        assert_eq!(uri.extras.output_substitution, OutputSubstitution::Disabled);
1✔
1786
    }
1✔
1787

1788
    #[test]
1789
    /// Ensures output substitution is disabled for v1 proposals in v2 logic.
1790
    fn test_unchecked_from_payload_disables_output_substitution_for_v1() {
1✔
1791
        let base64 = ORIGINAL_PSBT;
1✔
1792
        let query = "v=1";
1✔
1793
        let payload = format!("{base64}\n{query}");
1✔
1794
        let receiver = Receiver { state: Initialized {}, session_context: SHARED_CONTEXT.clone() };
1✔
1795
        let proposal = receiver
1✔
1796
            .unchecked_from_payload(&payload)
1✔
1797
            .expect("unchecked_from_payload should parse valid v1 PSBT payload");
1✔
1798
        assert_eq!(proposal.params.output_substitution, OutputSubstitution::Disabled);
1✔
1799
    }
1✔
1800

1801
    #[test]
1802
    fn test_getting_psbt_to_sign() {
1✔
1803
        let provisional_proposal = ProvisionalProposal {
1✔
1804
            psbt_context: PsbtContext {
1✔
1805
                payjoin_psbt: PARSED_PAYJOIN_PROPOSAL.clone(),
1✔
1806
                original_psbt: PARSED_ORIGINAL_PSBT.clone(),
1✔
1807
            },
1✔
1808
        };
1✔
1809
        let receiver =
1✔
1810
            Receiver { state: provisional_proposal, session_context: SHARED_CONTEXT.clone() };
1✔
1811
        let psbt = receiver.psbt_to_sign();
1✔
1812
        assert_eq!(psbt, PARSED_PAYJOIN_PROPOSAL.clone());
1✔
1813
    }
1✔
1814
}
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

© 2025 Coveralls, Inc