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

payjoin / rust-payjoin / 18230715366

03 Oct 2025 06:34PM UTC coverage: 84.18% (-0.06%) from 84.237%
18230715366

Pull #1141

github

web-flow
Merge 1460d24bb into 5335134f3
Pull Request #1141: Return String instead of Url for endpoint accessors

97 of 99 new or added lines in 8 files covered. (97.98%)

4 existing lines in 3 files now uncovered.

8934 of 10613 relevant lines covered (84.18%)

466.0 hits per line

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

88.95
/payjoin/src/core/send/v2/mod.rs
1
//! Send BIP 77 Payjoin v2
2
//!
3
//! This module contains types and methods used to implement sending via [BIP77
4
//! Payjoin](https://github.com/bitcoin/bips/blob/master/bip-0077.md).
5
//!
6
//! Usage is pretty simple:
7
//!
8
//! 1. Parse BIP21 as [`payjoin::Uri`](crate::Uri)
9
//! 2. Construct URI request parameters, a finalized “Original PSBT” paying .amount to .address
10
//! 3. (optional) Spawn a thread or async task that will broadcast the original PSBT fallback after
11
//!    delay (e.g. 1 minute) unless canceled
12
//! 4. Construct the [`Sender`] using [`SenderBuilder`] with the PSBT and payjoin uri
13
//! 5. Send the request(s) and receive response(s) by following on the extracted Context
14
//! 6. Sign and finalize the Payjoin Proposal PSBT
15
//! 7. Broadcast the Payjoin Transaction (and cancel the optional fallback broadcast)
16
//!
17
//! This crate is runtime-agnostic. Data persistence, chain interactions, and networking may be
18
//! provided by custom implementations or copy the reference
19
//! [`payjoin-cli`](https://github.com/payjoin/rust-payjoin/tree/master/payjoin-cli) for bitcoind,
20
//! [`nolooking`](https://github.com/chaincase-app/nolooking) for LND, or
21
//! [`bitmask-core`](https://github.com/diba-io/bitmask-core) BDK integration. Bring your own
22
//! wallet and http client.
23
//!
24
//! OHTTP Privacy Warning
25
//! Encapsulated requests whether GET or POST—**must not be retried or reused**.
26
//! Retransmitting the same ciphertext (including via automatic retries) breaks the unlinkability and privacy guarantees of OHTTP,
27
//! as it allows the relay to correlate requests by comparing ciphertexts.
28
//! Note: Even fresh requests may be linkable via metadata (e.g. client IP, request timing),
29
//! but request reuse makes correlation trivial for the relay.
30

31
use bitcoin::hashes::{sha256, Hash};
32
use bitcoin::Address;
33
pub use error::{CreateRequestError, EncapsulationError};
34
use error::{InternalCreateRequestError, InternalEncapsulationError};
35
use ohttp::ClientResponse;
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::BuildSenderError;
41
use super::*;
42
use crate::error::{InternalReplayError, ReplayError};
43
use crate::hpke::{decrypt_message_b, encrypt_message_a, HpkeSecretKey};
44
use crate::ohttp::{ohttp_encapsulate, process_get_res, process_post_res};
45
use crate::persist::{
46
    MaybeFatalTransition, MaybeSuccessTransitionWithNoResults, NextStateTransition,
47
};
48
use crate::uri::v2::PjParam;
49
use crate::uri::ShortId;
50
use crate::{HpkeKeyPair, HpkePublicKey, IntoUrl, OhttpKeys, PjUri, Request};
51

52
mod error;
53
mod session;
54

55
/// A builder to construct the properties of a [`Sender`].
56
/// V2 SenderBuilder differs from V1 in that it does not allow the receiver's output substitution preference to be disabled.
57
/// This is because all communications with the receiver are end-to-end authenticated. So a
58
/// malicious man in the middle can't substitute outputs, only the receiver can.
59
/// The receiver can always choose not to substitute outputs, however.
60
#[derive(Clone)]
61
pub struct SenderBuilder {
62
    pj_param: crate::uri::v2::PjParam,
63
    output_substitution: OutputSubstitution,
64
    psbt_ctx_builder: PsbtContextBuilder,
65
}
66

67
impl SenderBuilder {
68
    /// Prepare the context from which to make Sender requests
69
    ///
70
    /// Call [`SenderBuilder::build_recommended()`] or other `build` methods
71
    /// to create a [`Sender`]
72
    pub fn new(psbt: Psbt, uri: PjUri) -> Self {
10✔
73
        match uri.extras.pj_param {
10✔
74
            #[cfg(feature = "v1")]
75
            crate::uri::PjParam::V1(_) => unimplemented!("V2 SenderBuilder only supports v2 URLs"),
×
76
            crate::uri::PjParam::V2(pj_param) =>
10✔
77
                Self::from_parts(psbt, &pj_param, &uri.address, uri.amount),
10✔
78
        }
79
    }
10✔
80

81
    pub fn from_parts(
11✔
82
        psbt: Psbt,
11✔
83
        pj_param: &PjParam,
11✔
84
        address: &Address,
11✔
85
        amount: Option<Amount>,
11✔
86
    ) -> Self {
11✔
87
        Self {
11✔
88
            pj_param: pj_param.clone(),
11✔
89
            // Ignore the receiver's output substitution preference, because all
11✔
90
            // communications with the receiver are end-to-end authenticated. So a
11✔
91
            // malicious man in the middle can't substitute outputs, only the receiver can.
11✔
92
            output_substitution: OutputSubstitution::Enabled,
11✔
93
            psbt_ctx_builder: PsbtContextBuilder::new(psbt, address.script_pubkey(), amount),
11✔
94
        }
11✔
95
    }
11✔
96

97
    /// Disable output substitution even if the receiver didn't.
98
    ///
99
    /// This forbids receiver switching output or decreasing amount.
100
    /// It is generally **not** recommended to set this as it may prevent the receiver from
101
    /// doing advanced operations such as opening LN channels and it also guarantees the
102
    /// receiver will **not** reward the sender with a discount.
103
    pub fn always_disable_output_substitution(self) -> Self {
1✔
104
        Self { output_substitution: OutputSubstitution::Disabled, ..self }
1✔
105
    }
1✔
106

107
    // Calculate the recommended fee contribution for an Original PSBT.
108
    //
109
    // BIP 78 recommends contributing `originalPSBTFeeRate * vsize(sender_input_type)`.
110
    // The minfeerate parameter is set if the contribution is available in change.
111
    //
112
    // This method fails if no recommendation can be made or if the PSBT is malformed.
113
    pub fn build_recommended(
8✔
114
        self,
8✔
115
        min_fee_rate: FeeRate,
8✔
116
    ) -> Result<NextStateTransition<SessionEvent, Sender<WithReplyKey>>, BuildSenderError> {
8✔
117
        let psbt_ctx =
8✔
118
            self.psbt_ctx_builder.build_recommended(min_fee_rate, self.output_substitution)?;
8✔
119
        Ok(Self::v2_transition_from_psbt_ctx(self.pj_param, psbt_ctx))
8✔
120
    }
8✔
121

122
    /// Offer the receiver contribution to pay for his input.
123
    ///
124
    /// These parameters will allow the receiver to take `max_fee_contribution` from given change
125
    /// output to pay for additional inputs. The recommended fee is `size_of_one_input * fee_rate`.
126
    ///
127
    /// `change_index` specifies which output can be used to pay fee. If `None` is provided, then
128
    /// the output is auto-detected unless the supplied transaction has more than two outputs.
129
    ///
130
    /// `clamp_fee_contribution` decreases fee contribution instead of erroring.
131
    ///
132
    /// If this option is true and a transaction with change amount lower than fee
133
    /// contribution is provided then instead of returning error the fee contribution will
134
    /// be just lowered in the request to match the change amount.
135
    pub fn build_with_additional_fee(
1✔
136
        self,
1✔
137
        max_fee_contribution: bitcoin::Amount,
1✔
138
        change_index: Option<usize>,
1✔
139
        min_fee_rate: FeeRate,
1✔
140
        clamp_fee_contribution: bool,
1✔
141
    ) -> Result<NextStateTransition<SessionEvent, Sender<WithReplyKey>>, BuildSenderError> {
1✔
142
        let psbt_ctx = self.psbt_ctx_builder.build_with_additional_fee(
1✔
143
            max_fee_contribution,
1✔
144
            change_index,
1✔
145
            min_fee_rate,
1✔
146
            clamp_fee_contribution,
1✔
147
            self.output_substitution,
1✔
148
        )?;
×
149
        Ok(Self::v2_transition_from_psbt_ctx(self.pj_param, psbt_ctx))
1✔
150
    }
1✔
151

152
    /// Perform Payjoin without incentivizing the payee to cooperate.
153
    ///
154
    /// While it's generally better to offer some contribution some users may wish not to.
155
    /// This function disables contribution.
156
    pub fn build_non_incentivizing(
2✔
157
        self,
2✔
158
        min_fee_rate: FeeRate,
2✔
159
    ) -> Result<NextStateTransition<SessionEvent, Sender<WithReplyKey>>, BuildSenderError> {
2✔
160
        let psbt_ctx = self
2✔
161
            .psbt_ctx_builder
2✔
162
            .build_non_incentivizing(min_fee_rate, self.output_substitution)?;
2✔
163
        Ok(Self::v2_transition_from_psbt_ctx(self.pj_param, psbt_ctx))
2✔
164
    }
2✔
165

166
    /// Helper function that takes a V1 sender build result and wraps it in a V2 Sender,
167
    /// returning the appropriate state transition.
168
    fn v2_transition_from_psbt_ctx(
11✔
169
        pj_param: PjParam,
11✔
170
        psbt_ctx: PsbtContext,
11✔
171
    ) -> NextStateTransition<SessionEvent, Sender<WithReplyKey>> {
11✔
172
        let reply_key = HpkeKeyPair::gen_keypair().0;
11✔
173
        let sender = Sender {
11✔
174
            state: WithReplyKey,
11✔
175
            pj_param: pj_param.clone(),
11✔
176
            psbt_ctx: psbt_ctx.clone(),
11✔
177
            reply_key: reply_key.clone(),
11✔
178
        };
11✔
179
        NextStateTransition::success(SessionEvent::Created(Box::new(sender.clone())), sender)
11✔
180
    }
11✔
181
}
182

183
mod sealed {
184
    pub trait State {}
185

186
    impl State for super::WithReplyKey {}
187
    impl State for super::PollingForProposal {}
188
}
189

190
/// Sealed trait for V2 send session states.
191
///
192
/// This trait is sealed to prevent external implementations. Only types within this crate
193
/// can implement this trait, ensuring type safety and protocol integrity.
194
pub trait State: sealed::State {}
195

196
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
197
pub struct Sender<State> {
198
    pub(crate) state: State,
199
    /// The endpoint in the Payjoin URI
200
    pub(crate) pj_param: PjParam,
201
    /// The Original PSBT context
202
    pub(crate) psbt_ctx: PsbtContext,
203
    /// The secret key to decrypt the receiver's reply.
204
    pub(crate) reply_key: HpkeSecretKey,
205
}
206

207
impl<State> core::ops::Deref for Sender<State> {
208
    type Target = State;
209

UNCOV
210
    fn deref(&self) -> &Self::Target { &self.state }
×
211
}
212

213
impl<State> core::ops::DerefMut for Sender<State> {
214
    fn deref_mut(&mut self) -> &mut Self::Target { &mut self.state }
×
215
}
216

217
impl<State> Sender<State> {
218
    /// The endpoint in the Payjoin URI
219
    pub fn endpoint(&self) -> String { self.pj_param.endpoint().to_string() }
5✔
220
}
221

222
/// Represents the various states of a Payjoin send session during the protocol flow.
223
///
224
/// This provides type erasure for the send session state, allowing the session to be replayed
225
/// and the state to be updated with the next event over a uniform interface.
226
#[derive(Debug, Clone, PartialEq, Eq)]
227
pub enum SendSession {
228
    WithReplyKey(Sender<WithReplyKey>),
229
    PollingForProposal(Sender<PollingForProposal>),
230
    ProposalReceived(Psbt),
231
    Closed(SessionOutcome),
232
}
233

234
impl SendSession {
235
    fn new(sender: Sender<WithReplyKey>) -> Self { SendSession::WithReplyKey(sender) }
3✔
236

237
    fn process_event(
1✔
238
        self,
1✔
239
        event: SessionEvent,
1✔
240
    ) -> Result<SendSession, ReplayError<Self, SessionEvent>> {
1✔
241
        match (self, event) {
1✔
242
            (SendSession::WithReplyKey(state), SessionEvent::PostedOriginalPsbt()) =>
1✔
243
                Ok(state.apply_polling_for_proposal()),
1✔
244
            (
245
                SendSession::PollingForProposal(_state),
×
246
                SessionEvent::ReceivedProposalPsbt(proposal),
×
247
            ) => Ok(SendSession::ProposalReceived(proposal)),
×
248
            (_, SessionEvent::Closed(session_outcome)) => Ok(SendSession::Closed(session_outcome)),
×
249
            (current_state, event) => Err(InternalReplayError::InvalidEvent(
×
250
                Box::new(event),
×
251
                Some(Box::new(current_state)),
×
252
            )
×
253
            .into()),
×
254
        }
255
    }
1✔
256
}
257

258
/// A payjoin V2 sender, allowing the construction of a payjoin V2 request
259
/// and the resulting [`V2PostContext`].
260
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
261
pub struct WithReplyKey;
262

263
impl Sender<WithReplyKey> {
264
    /// Construct serialized Request and Context from a Payjoin Proposal.
265
    ///
266
    /// Important: This request must not be retried or reused on failure.
267
    /// Retransmitting the same ciphertext breaks OHTTP privacy properties.
268
    /// The specific concern is that the relay can see that a request is being retried,
269
    /// which leaks that it's all the same request.
270
    ///
271
    /// This method requires the `rs` pubkey to be extracted from the endpoint
272
    /// and has no fallback to v1.
273
    pub fn create_v2_post_request(
6✔
274
        &self,
6✔
275
        ohttp_relay: impl IntoUrl,
6✔
276
    ) -> Result<(Request, V2PostContext), CreateRequestError> {
6✔
277
        if self.pj_param.expiration().elapsed() {
6✔
278
            return Err(InternalCreateRequestError::Expired(self.pj_param.expiration()).into());
2✔
279
        }
4✔
280

281
        let mut sanitized_psbt = self.psbt_ctx.original_psbt.clone();
4✔
282
        clear_unneeded_fields(&mut sanitized_psbt);
4✔
283
        let body = serialize_v2_body(
4✔
284
            &sanitized_psbt,
4✔
285
            self.psbt_ctx.output_substitution,
4✔
286
            self.psbt_ctx.fee_contribution,
4✔
287
            self.psbt_ctx.min_fee_rate,
4✔
288
        )?;
×
289
        let base_url = self.pj_param.endpoint().clone();
4✔
290
        let ohttp_keys = self.pj_param.ohttp_keys();
4✔
291
        let (request, ohttp_ctx) = extract_request(
4✔
292
            ohttp_relay,
4✔
293
            self.reply_key.clone(),
4✔
294
            body,
4✔
295
            base_url,
4✔
296
            self.pj_param.receiver_pubkey().clone(),
4✔
297
            ohttp_keys,
4✔
298
        )?;
×
299
        Ok((
4✔
300
            request,
4✔
301
            V2PostContext {
4✔
302
                pj_param: self.pj_param.clone(),
4✔
303
                psbt_ctx: self.psbt_ctx.clone(),
4✔
304
                reply_key: self.reply_key.clone(),
4✔
305
                ohttp_ctx,
4✔
306
            },
4✔
307
        ))
4✔
308
    }
6✔
309

310
    /// Processes the response for the initial POST message from the sender
311
    /// client in the v2 Payjoin protocol.
312
    ///
313
    /// This function decapsulates the response using the provided OHTTP
314
    /// context. If the encapsulated response status is successful, it
315
    /// indicates that the the Original PSBT been accepted. Otherwise, it
316
    /// returns an error with the encapsulated response status code.
317
    ///
318
    /// After this function is called, the sender can poll for a Proposal PSBT
319
    /// from the receiver using the returned [`PollingForProposal`].
320
    pub fn process_response(
3✔
321
        self,
3✔
322
        response: &[u8],
3✔
323
        post_ctx: V2PostContext,
3✔
324
    ) -> MaybeFatalTransition<SessionEvent, Sender<PollingForProposal>, EncapsulationError> {
3✔
325
        match process_post_res(response, post_ctx.ohttp_ctx) {
3✔
326
            Ok(()) => {}
3✔
327
            Err(e) =>
×
328
                if e.is_fatal() {
×
329
                    return MaybeFatalTransition::fatal(
×
330
                        SessionEvent::Closed(SessionOutcome::Failure),
×
331
                        InternalEncapsulationError::DirectoryResponse(e).into(),
×
332
                    );
333
                } else {
334
                    return MaybeFatalTransition::transient(
×
335
                        InternalEncapsulationError::DirectoryResponse(e).into(),
×
336
                    );
337
                },
338
        }
339

340
        let sender = Sender {
3✔
341
            state: PollingForProposal,
3✔
342
            pj_param: post_ctx.pj_param,
3✔
343
            psbt_ctx: post_ctx.psbt_ctx,
3✔
344
            reply_key: post_ctx.reply_key,
3✔
345
        };
3✔
346
        MaybeFatalTransition::success(SessionEvent::PostedOriginalPsbt(), sender)
3✔
347
    }
3✔
348

349
    pub(crate) fn apply_polling_for_proposal(self) -> SendSession {
1✔
350
        SendSession::PollingForProposal(Sender {
1✔
351
            state: PollingForProposal,
1✔
352
            pj_param: self.pj_param,
1✔
353
            psbt_ctx: self.psbt_ctx,
1✔
354
            reply_key: self.reply_key,
1✔
355
        })
1✔
356
    }
1✔
357
}
358

359
pub(crate) fn extract_request(
4✔
360
    ohttp_relay: impl IntoUrl,
4✔
361
    reply_key: HpkeSecretKey,
4✔
362
    body: Vec<u8>,
4✔
363
    url: Url,
4✔
364
    receiver_pubkey: HpkePublicKey,
4✔
365
    ohttp_keys: &OhttpKeys,
4✔
366
) -> Result<(Request, ClientResponse), CreateRequestError> {
4✔
367
    let ohttp_relay = ohttp_relay.into_url()?;
4✔
368
    let body = encrypt_message_a(
4✔
369
        body,
4✔
370
        &HpkeKeyPair::from_secret_key(&reply_key).public_key().clone(),
4✔
371
        &receiver_pubkey,
4✔
372
    )
373
    .map_err(InternalCreateRequestError::Hpke)?;
4✔
374

375
    let (body, ohttp_ctx) = ohttp_encapsulate(ohttp_keys, "POST", url.as_str(), Some(&body))
4✔
376
        .map_err(InternalCreateRequestError::OhttpEncapsulation)?;
4✔
377
    tracing::debug!("ohttp_relay_url: {ohttp_relay:?}");
4✔
378
    let directory_base = url.join("/").map_err(|e| InternalCreateRequestError::Url(e.into()))?;
4✔
379
    let full_ohttp_relay = ohttp_relay
4✔
380
        .join(&format!("/{directory_base}"))
4✔
381
        .map_err(|e| InternalCreateRequestError::Url(e.into()))?;
4✔
382
    let request = Request::new_v2(&full_ohttp_relay, &body);
4✔
383
    Ok((request, ohttp_ctx))
4✔
384
}
4✔
385

386
pub(crate) fn serialize_v2_body(
5✔
387
    psbt: &Psbt,
5✔
388
    output_substitution: OutputSubstitution,
5✔
389
    fee_contribution: Option<AdditionalFeeContribution>,
5✔
390
    min_fee_rate: FeeRate,
5✔
391
) -> Result<Vec<u8>, CreateRequestError> {
5✔
392
    // Grug say localhost base be discarded anyway. no big brain needed.
393
    let base_url = "http://localhost";
5✔
394

395
    let placeholder_url =
5✔
396
        serialize_url(base_url, output_substitution, fee_contribution, min_fee_rate, Version::Two);
5✔
397
    let query_params = placeholder_url.query().unwrap_or_default();
5✔
398
    let base64 = psbt.to_string();
5✔
399
    Ok(format!("{base64}\n{query_params}").into_bytes())
5✔
400
}
5✔
401

402
/// Data required to validate the POST response.
403
///
404
/// This type is used to process a BIP77 POST response.
405
/// Call [`Sender<V2PostContext>::process_response`] on it to continue the BIP77 flow.
406
pub struct V2PostContext {
407
    /// The endpoint in the Payjoin URI
408
    pub(crate) pj_param: PjParam,
409
    pub(crate) psbt_ctx: PsbtContext,
410
    pub(crate) reply_key: HpkeSecretKey,
411
    pub(crate) ohttp_ctx: ohttp::ClientResponse,
412
}
413

414
/// Data required to validate the GET response.
415
///
416
/// This type is used to make a BIP77 GET request and process the response.
417
/// Call [`Sender<PollingForProposal>::process_response`] on it to continue the BIP77 flow.
418
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
419
pub struct PollingForProposal;
420

421
impl ResponseError {
422
    fn from_slice(bytes: &[u8]) -> Result<Self, serde_json::Error> {
3✔
423
        let trimmed_bytes = bytes.split(|&byte| byte == 0).next().unwrap_or(bytes);
106✔
424
        let value: serde_json::Value = serde_json::from_slice(trimmed_bytes)?;
3✔
425
        Ok(ResponseError::from_json(value))
1✔
426
    }
3✔
427
}
428

429
impl Sender<PollingForProposal> {
430
    /// Construct an OHTTP Encapsulated HTTP GET request for the Proposal PSBT
431
    pub fn create_poll_request(
5✔
432
        &self,
5✔
433
        ohttp_relay: impl IntoUrl,
5✔
434
    ) -> Result<(Request, ohttp::ClientResponse), CreateRequestError> {
5✔
435
        // TODO unify with receiver's fn short_id_from_pubkey
436
        let hash = sha256::Hash::hash(
5✔
437
            &HpkeKeyPair::from_secret_key(&self.reply_key).public_key().to_compressed_bytes(),
5✔
438
        );
439
        let mailbox: ShortId = hash.into();
5✔
440
        let url = Url::parse(self.pj_param.endpoint().as_str())
5✔
441
            .expect("Could not parse url")
5✔
442
            .join(&mailbox.to_string())
5✔
443
            .map_err(|e| InternalCreateRequestError::Url(e.into()))?;
5✔
444
        let body = encrypt_message_a(
5✔
445
            Vec::new(),
5✔
446
            HpkeKeyPair::from_secret_key(&self.reply_key).public_key(),
5✔
447
            self.pj_param.receiver_pubkey(),
5✔
448
        )
449
        .map_err(InternalCreateRequestError::Hpke)?;
5✔
450
        let ohttp_keys = self.pj_param.ohttp_keys();
5✔
451
        let (body, ohttp_ctx) = ohttp_encapsulate(ohttp_keys, "GET", url.as_str(), Some(&body))
5✔
452
            .map_err(InternalCreateRequestError::OhttpEncapsulation)?;
5✔
453

454
        let url = ohttp_relay.into_url().map_err(InternalCreateRequestError::Url)?;
5✔
455
        Ok((Request::new_v2(&url, &body), ohttp_ctx))
5✔
456
    }
5✔
457

458
    /// Processes the response for the final GET message from the sender client
459
    /// in the v2 Payjoin protocol.
460
    ///
461
    /// This function decapsulates the response using the provided OHTTP
462
    /// context. A successful response can either be a Proposal PSBT or an
463
    /// ACCEPTED message indicating no Proposal PSBT is available yet.
464
    /// Otherwise, it returns an error with the encapsulated status code.
465
    ///
466
    /// After this function is called, the sender can sign and finalize the
467
    /// PSBT and broadcast the resulting Payjoin transaction to the network.
468
    pub fn process_response(
4✔
469
        self,
4✔
470
        response: &[u8],
4✔
471
        ohttp_ctx: ohttp::ClientResponse,
4✔
472
    ) -> MaybeSuccessTransitionWithNoResults<
4✔
473
        SessionEvent,
4✔
474
        Psbt,
4✔
475
        Sender<PollingForProposal>,
4✔
476
        ResponseError,
4✔
477
    > {
4✔
478
        let body = match process_get_res(response, ohttp_ctx) {
4✔
479
            Ok(Some(body)) => body,
3✔
480
            Ok(None) => return MaybeSuccessTransitionWithNoResults::no_results(self.clone()),
1✔
481
            Err(e) =>
×
482
                if e.is_fatal() {
×
483
                    return MaybeSuccessTransitionWithNoResults::fatal(
×
484
                        SessionEvent::Closed(SessionOutcome::Failure),
×
485
                        InternalEncapsulationError::DirectoryResponse(e).into(),
×
486
                    );
487
                } else {
488
                    return MaybeSuccessTransitionWithNoResults::transient(
×
489
                        InternalEncapsulationError::DirectoryResponse(e).into(),
×
490
                    );
491
                },
492
        };
493

494
        let body = match decrypt_message_b(
3✔
495
            &body,
3✔
496
            self.pj_param.receiver_pubkey().clone(),
3✔
497
            self.reply_key.clone(),
3✔
498
        ) {
3✔
499
            Ok(body) => body,
3✔
500
            Err(e) =>
×
501
                return MaybeSuccessTransitionWithNoResults::fatal(
×
502
                    SessionEvent::Closed(SessionOutcome::Failure),
×
503
                    InternalEncapsulationError::Hpke(e).into(),
×
504
                ),
505
        };
506

507
        if let Ok(resp_err) = ResponseError::from_slice(&body) {
3✔
508
            return MaybeSuccessTransitionWithNoResults::fatal(
1✔
509
                SessionEvent::Closed(SessionOutcome::Failure),
1✔
510
                resp_err,
1✔
511
            );
512
        }
2✔
513

514
        let proposal = match Psbt::deserialize(&body) {
2✔
515
            Ok(proposal) => proposal,
2✔
516
            Err(e) =>
×
517
                return MaybeSuccessTransitionWithNoResults::fatal(
×
518
                    SessionEvent::Closed(SessionOutcome::Failure),
×
519
                    InternalProposalError::Psbt(e).into(),
×
520
                ),
521
        };
522
        let processed_proposal = match self.psbt_ctx.clone().process_proposal(proposal) {
2✔
523
            Ok(processed_proposal) => processed_proposal,
2✔
524
            Err(e) =>
×
525
                return MaybeSuccessTransitionWithNoResults::fatal(
×
526
                    SessionEvent::Closed(SessionOutcome::Failure),
×
527
                    e.into(),
×
528
                ),
529
        };
530

531
        MaybeSuccessTransitionWithNoResults::success(
2✔
532
            processed_proposal.clone(),
2✔
533
            SessionEvent::ReceivedProposalPsbt(processed_proposal),
2✔
534
        )
535
    }
4✔
536
}
537

538
#[cfg(test)]
539
mod test {
540
    use std::str::FromStr;
541
    use std::time::{Duration, SystemTime};
542

543
    use bitcoin::hex::FromHex;
544
    use bitcoin::Address;
545
    use payjoin_test_utils::{BoxError, EXAMPLE_URL, KEM, KEY_ID, PARSED_ORIGINAL_PSBT, SYMMETRIC};
546

547
    use super::*;
548
    use crate::persist::NoopSessionPersister;
549
    use crate::receive::v2::ReceiverBuilder;
550
    use crate::time::Time;
551
    use crate::OhttpKeys;
552

553
    const SERIALIZED_BODY_V2: &str = "63484e696450384241484d43414141414159386e757447674a647959475857694245623435486f65396c5747626b78682f36624e694f4a6443447544414141414141442b2f2f2f2f41747956754155414141414146366b554865684a38476e536442554f4f7636756a584c72576d734a5244434867495165414141414141415871525233514a62627a30686e513849765130667074476e2b766f746e656f66544141414141414542494b6762317755414141414146366b55336b34656b47484b57524e6241317256357452356b455644564e4348415163584667415578347046636c4e56676f31575741644e3153594e583874706854414243477343527a424541694238512b41366465702b527a393276687932366c5430416a5a6e3450524c6938426639716f422f434d6b30774967502f526a3250575a3367456a556b546c6844524e415130675877544f3774396e2b563134705a366f6c6a554249514d566d7341616f4e5748564d5330324c6654536530653338384c4e697450613155515a794f6968592b464667414241425941464562324769753663344b4f35595730706677336c4770396a4d55554141413d0a763d32";
554

555
    fn create_sender_context(
3✔
556
        expiration: Time,
3✔
557
    ) -> Result<super::Sender<super::WithReplyKey>, BoxError> {
3✔
558
        let endpoint = Url::parse("http://localhost:1234")?;
3✔
559
        let pj_param = crate::uri::v2::PjParam::new(
3✔
560
            endpoint,
3✔
561
            crate::uri::ShortId::try_from(&b"12345670"[..]).expect("valid short id"),
3✔
562
            expiration,
3✔
563
            OhttpKeys(
3✔
564
                ohttp::KeyConfig::new(KEY_ID, KEM, Vec::from(SYMMETRIC)).expect("valid key config"),
3✔
565
            ),
3✔
566
            HpkeKeyPair::gen_keypair().1,
3✔
567
        );
568
        Ok(super::Sender {
3✔
569
            state: super::WithReplyKey,
3✔
570
            pj_param,
3✔
571
            psbt_ctx: PsbtContext {
3✔
572
                original_psbt: PARSED_ORIGINAL_PSBT.clone(),
3✔
573
                output_substitution: OutputSubstitution::Enabled,
3✔
574
                fee_contribution: None,
3✔
575
                min_fee_rate: FeeRate::ZERO,
3✔
576
                payee: ScriptBuf::from(vec![0x00]),
3✔
577
            },
3✔
578
            reply_key: HpkeKeyPair::gen_keypair().0,
3✔
579
        })
3✔
580
    }
3✔
581

582
    #[test]
583
    fn test_serialize_v2() -> Result<(), BoxError> {
1✔
584
        let expiration =
1✔
585
            Time::from_now(Duration::from_secs(60)).expect("expiration should be valid");
1✔
586
        let sender = create_sender_context(expiration)?;
1✔
587
        let body = serialize_v2_body(
1✔
588
            &sender.psbt_ctx.original_psbt,
1✔
589
            sender.psbt_ctx.output_substitution,
1✔
590
            sender.psbt_ctx.fee_contribution,
1✔
591
            sender.psbt_ctx.min_fee_rate,
1✔
592
        );
593
        assert_eq!(body.as_ref().unwrap(), &<Vec<u8> as FromHex>::from_hex(SERIALIZED_BODY_V2)?,);
1✔
594
        Ok(())
1✔
595
    }
1✔
596

597
    #[test]
598
    fn test_extract_v2_success() -> Result<(), BoxError> {
1✔
599
        let expiration =
1✔
600
            Time::from_now(Duration::from_secs(60)).expect("expiration should be valid");
1✔
601
        let sender = create_sender_context(expiration)?;
1✔
602
        let ohttp_relay = EXAMPLE_URL;
1✔
603
        let result = sender.create_v2_post_request(ohttp_relay);
1✔
604
        let (request, context) = result.expect("Result should be ok");
1✔
605
        assert!(!request.body.is_empty(), "Request body should not be empty");
1✔
606
        assert_eq!(
1✔
607
            request.url.to_string(),
1✔
608
            format!("{}/{}", EXAMPLE_URL, sender.pj_param.endpoint().join("/")?)
1✔
609
        );
610
        assert_eq!(context.psbt_ctx.original_psbt, sender.psbt_ctx.original_psbt);
1✔
611
        Ok(())
1✔
612
    }
1✔
613

614
    #[test]
615
    fn test_extract_v2_fails_when_expired() -> Result<(), BoxError> {
1✔
616
        // Create a sender with an already expired timestamp
617
        let expiration = Time::try_from(SystemTime::now() - Duration::from_secs(1))
1✔
618
            .expect("time in the past should be representable");
1✔
619

620
        let sender = create_sender_context(expiration)?;
1✔
621
        let ohttp_relay = EXAMPLE_URL;
1✔
622
        let result = sender.create_v2_post_request(ohttp_relay);
1✔
623
        assert!(result.is_err(), "Extract v2 expected expiration error, but it succeeded");
1✔
624

625
        match result {
1✔
626
            Ok(_) => panic!("Expected error, got success"),
×
627
            Err(error) => assert_eq!(format!("{error}"), "session expired",),
1✔
628
        }
629
        Ok(())
1✔
630
    }
1✔
631

632
    #[test]
633
    fn test_v2_sender_builder() {
1✔
634
        let address = Address::from_str("2N47mmrWXsNBvQR6k78hWJoTji57zXwNcU7")
1✔
635
            .expect("valid address")
1✔
636
            .assume_checked();
1✔
637
        let directory = EXAMPLE_URL;
1✔
638
        let ohttp_keys = OhttpKeys(
1✔
639
            ohttp::KeyConfig::new(KEY_ID, KEM, Vec::from(SYMMETRIC)).expect("valid key config"),
1✔
640
        );
1✔
641
        let pj_uri = ReceiverBuilder::new(address.clone(), directory, ohttp_keys)
1✔
642
            .expect("constructor on test vector should not fail")
1✔
643
            .build()
1✔
644
            .save(&NoopSessionPersister::default())
1✔
645
            .expect("receiver should succeed")
1✔
646
            .pj_uri();
1✔
647
        let req_ctx = SenderBuilder::new(PARSED_ORIGINAL_PSBT.clone(), pj_uri.clone())
1✔
648
            .build_recommended(FeeRate::BROADCAST_MIN)
1✔
649
            .expect("build on test vector should succeed")
1✔
650
            .save(&NoopSessionPersister::default())
1✔
651
            .expect("sender should succeed");
1✔
652
        // v2 senders may always override the receiver's `pjos` parameter to enable output
653
        // substitution
654
        assert_eq!(req_ctx.psbt_ctx.output_substitution, OutputSubstitution::Enabled);
1✔
655
        assert_eq!(&req_ctx.psbt_ctx.payee, &address.script_pubkey());
1✔
656
        let fee_contribution =
1✔
657
            req_ctx.psbt_ctx.fee_contribution.expect("sender should contribute fees");
1✔
658
        assert_eq!(fee_contribution.max_amount, Amount::from_sat(91));
1✔
659
        assert_eq!(fee_contribution.vout, 0);
1✔
660
        assert_eq!(req_ctx.psbt_ctx.min_fee_rate, FeeRate::from_sat_per_kwu(250));
1✔
661
        // ensure that the other builder methods also enable output substitution
662
        let req_ctx = SenderBuilder::new(PARSED_ORIGINAL_PSBT.clone(), pj_uri.clone())
1✔
663
            .build_non_incentivizing(FeeRate::BROADCAST_MIN)
1✔
664
            .expect("build on test vector should succeed")
1✔
665
            .save(&NoopSessionPersister::default())
1✔
666
            .expect("sender should succeed");
1✔
667
        assert_eq!(req_ctx.psbt_ctx.output_substitution, OutputSubstitution::Enabled);
1✔
668
        let req_ctx = SenderBuilder::new(PARSED_ORIGINAL_PSBT.clone(), pj_uri.clone())
1✔
669
            .build_with_additional_fee(Amount::ZERO, Some(0), FeeRate::BROADCAST_MIN, false)
1✔
670
            .expect("build on test vector should succeed")
1✔
671
            .save(&NoopSessionPersister::default())
1✔
672
            .expect("sender should succeed");
1✔
673
        assert_eq!(req_ctx.psbt_ctx.output_substitution, OutputSubstitution::Enabled);
1✔
674
        // ensure that a v2 sender may still disable output substitution if they prefer.
675
        let req_ctx = SenderBuilder::new(PARSED_ORIGINAL_PSBT.clone(), pj_uri)
1✔
676
            .always_disable_output_substitution()
1✔
677
            .build_recommended(FeeRate::BROADCAST_MIN)
1✔
678
            .expect("build on test vector should succeed")
1✔
679
            .save(&NoopSessionPersister::default())
1✔
680
            .expect("sender should succeed");
1✔
681
        assert_eq!(req_ctx.psbt_ctx.output_substitution, OutputSubstitution::Disabled);
1✔
682
    }
1✔
683
}
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