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

payjoin / rust-payjoin / 12324387724

13 Dec 2024 11:08PM UTC coverage: 67.045% (-0.04%) from 67.083%
12324387724

Pull #441

github

web-flow
Merge 4ea00db32 into 0891a5bf0
Pull Request #441: Propagate errors when retrieving exp parameter

32 of 60 new or added lines in 2 files covered. (53.33%)

2 existing lines in 1 file now uncovered.

3131 of 4670 relevant lines covered (67.04%)

986.08 hits per line

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

92.77
/payjoin/src/send/mod.rs
1
//! Send a Payjoin
2
//!
3
//! This module contains types and methods used to implement sending via [BIP 78
4
//! Payjoin](https://github.com/bitcoin/bips/blob/master/bip-0078.mediawiki).
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
use std::str::FromStr;
25

26
#[cfg(feature = "v2")]
27
use bitcoin::hashes::{sha256, Hash};
28
use bitcoin::psbt::Psbt;
29
use bitcoin::{Amount, FeeRate, Script, ScriptBuf, TxOut, Weight};
30
pub use error::{CreateRequestError, ResponseError, ValidationError};
31
pub(crate) use error::{InternalCreateRequestError, InternalValidationError};
32
#[cfg(feature = "v2")]
33
use serde::{Deserialize, Serialize};
34
use url::Url;
35

36
#[cfg(feature = "v2")]
37
use crate::hpke::{decrypt_message_b, encrypt_message_a, HpkeKeyPair, HpkePublicKey};
38
#[cfg(feature = "v2")]
39
use crate::ohttp::{ohttp_decapsulate, ohttp_encapsulate};
40
use crate::psbt::PsbtExt;
41
use crate::request::Request;
42
#[cfg(feature = "v2")]
43
use crate::uri::ShortId;
44
use crate::PjUri;
45

46
// See usize casts
47
#[cfg(not(any(target_pointer_width = "32", target_pointer_width = "64")))]
48
compile_error!("This crate currently only supports 32 bit and 64 bit architectures");
49

50
mod error;
51

52
type InternalResult<T> = Result<T, InternalValidationError>;
53

54
#[derive(Clone)]
55
pub struct SenderBuilder<'a> {
56
    psbt: Psbt,
57
    uri: PjUri<'a>,
58
    disable_output_substitution: bool,
59
    fee_contribution: Option<(bitcoin::Amount, Option<usize>)>,
60
    /// Decreases the fee contribution instead of erroring.
61
    ///
62
    /// If this option is true and a transaction with change amount lower than fee
63
    /// contribution is provided then instead of returning error the fee contribution will
64
    /// be just lowered in the request to match the change amount.
65
    clamp_fee_contribution: bool,
66
    min_fee_rate: FeeRate,
67
}
68

69
impl<'a> SenderBuilder<'a> {
70
    /// Prepare an HTTP request and request context to process the response
71
    ///
72
    /// An HTTP client will own the Request data while Context sticks around so
73
    /// a `(Request, Context)` tuple is returned from [`SenderBuilder::build_recommended()`]
74
    /// (or other `build` methods) to keep them separated.
75
    pub fn from_psbt_and_uri(psbt: Psbt, uri: PjUri<'a>) -> Result<Self, CreateRequestError> {
11✔
76
        Ok(Self {
11✔
77
            psbt,
11✔
78
            uri,
11✔
79
            // Sender's optional parameters
11✔
80
            disable_output_substitution: false,
11✔
81
            fee_contribution: None,
11✔
82
            clamp_fee_contribution: false,
11✔
83
            min_fee_rate: FeeRate::ZERO,
11✔
84
        })
11✔
85
    }
11✔
86

87
    /// Disable output substitution even if the receiver didn't.
88
    ///
89
    /// This forbids receiver switching output or decreasing amount.
90
    /// It is generally **not** recommended to set this as it may prevent the receiver from
91
    /// doing advanced operations such as opening LN channels and it also guarantees the
92
    /// receiver will **not** reward the sender with a discount.
93
    pub fn always_disable_output_substitution(mut self, disable: bool) -> Self {
×
94
        self.disable_output_substitution = disable;
×
95
        self
×
96
    }
×
97

98
    // Calculate the recommended fee contribution for an Original PSBT.
99
    //
100
    // BIP 78 recommends contributing `originalPSBTFeeRate * vsize(sender_input_type)`.
101
    // The minfeerate parameter is set if the contribution is available in change.
102
    //
103
    // This method fails if no recommendation can be made or if the PSBT is malformed.
104
    pub fn build_recommended(self, min_fee_rate: FeeRate) -> Result<Sender, CreateRequestError> {
3✔
105
        // TODO support optional batched payout scripts. This would require a change to
3✔
106
        // build() which now checks for a single payee.
3✔
107
        let mut payout_scripts = std::iter::once(self.uri.address.script_pubkey());
3✔
108

3✔
109
        // Check if the PSBT is a sweep transaction with only one output that's a payout script and no change
3✔
110
        if self.psbt.unsigned_tx.output.len() == 1
3✔
111
            && payout_scripts.all(|script| script == self.psbt.unsigned_tx.output[0].script_pubkey)
2✔
112
        {
113
            return self.build_non_incentivizing(min_fee_rate);
2✔
114
        }
1✔
115

116
        if let Some((additional_fee_index, fee_available)) = self
1✔
117
            .psbt
1✔
118
            .unsigned_tx
1✔
119
            .output
1✔
120
            .clone()
1✔
121
            .into_iter()
1✔
122
            .enumerate()
1✔
123
            .find(|(_, txo)| payout_scripts.all(|script| script != txo.script_pubkey))
2✔
124
            .map(|(i, txo)| (i, txo.value))
1✔
125
        {
126
            let mut input_pairs = self.psbt.input_pairs();
1✔
127
            let first_input_pair =
1✔
128
                input_pairs.next().ok_or(InternalCreateRequestError::NoInputs)?;
1✔
129
            let mut input_weight = first_input_pair
1✔
130
                .expected_input_weight()
1✔
131
                .map_err(InternalCreateRequestError::InputWeight)?;
1✔
132
            for input_pair in input_pairs {
1✔
133
                // use cheapest default if mixed input types
134
                if input_pair.address_type()? != first_input_pair.address_type()? {
×
135
                    input_weight =
×
136
                        bitcoin::transaction::InputWeightPrediction::P2TR_KEY_NON_DEFAULT_SIGHASH.weight()
×
137
                    // Lengths of txid, index and sequence: (32, 4, 4).
×
138
                    + Weight::from_non_witness_data_size(32 + 4 + 4);
×
139
                    break;
×
140
                }
×
141
            }
142

143
            let recommended_additional_fee = min_fee_rate * input_weight;
1✔
144
            if fee_available < recommended_additional_fee {
1✔
145
                log::warn!("Insufficient funds to maintain specified minimum feerate.");
×
146
                return self.build_with_additional_fee(
×
147
                    fee_available,
×
148
                    Some(additional_fee_index),
×
149
                    min_fee_rate,
×
150
                    true,
×
151
                );
×
152
            }
1✔
153
            return self.build_with_additional_fee(
1✔
154
                recommended_additional_fee,
1✔
155
                Some(additional_fee_index),
1✔
156
                min_fee_rate,
1✔
157
                false,
1✔
158
            );
1✔
159
        }
×
160
        self.build_non_incentivizing(min_fee_rate)
×
161
    }
3✔
162

163
    /// Offer the receiver contribution to pay for his input.
164
    ///
165
    /// These parameters will allow the receiver to take `max_fee_contribution` from given change
166
    /// output to pay for additional inputs. The recommended fee is `size_of_one_input * fee_rate`.
167
    ///
168
    /// `change_index` specifies which output can be used to pay fee. If `None` is provided, then
169
    /// the output is auto-detected unless the supplied transaction has more than two outputs.
170
    ///
171
    /// `clamp_fee_contribution` decreases fee contribution instead of erroring.
172
    ///
173
    /// If this option is true and a transaction with change amount lower than fee
174
    /// contribution is provided then instead of returning error the fee contribution will
175
    /// be just lowered in the request to match the change amount.
176
    pub fn build_with_additional_fee(
8✔
177
        mut self,
8✔
178
        max_fee_contribution: bitcoin::Amount,
8✔
179
        change_index: Option<usize>,
8✔
180
        min_fee_rate: FeeRate,
8✔
181
        clamp_fee_contribution: bool,
8✔
182
    ) -> Result<Sender, CreateRequestError> {
8✔
183
        self.fee_contribution = Some((max_fee_contribution, change_index));
8✔
184
        self.clamp_fee_contribution = clamp_fee_contribution;
8✔
185
        self.min_fee_rate = min_fee_rate;
8✔
186
        self.build()
8✔
187
    }
8✔
188

189
    /// Perform Payjoin without incentivizing the payee to cooperate.
190
    ///
191
    /// While it's generally better to offer some contribution some users may wish not to.
192
    /// This function disables contribution.
193
    pub fn build_non_incentivizing(
3✔
194
        mut self,
3✔
195
        min_fee_rate: FeeRate,
3✔
196
    ) -> Result<Sender, CreateRequestError> {
3✔
197
        // since this is a builder, these should already be cleared
3✔
198
        // but we'll reset them to be sure
3✔
199
        self.fee_contribution = None;
3✔
200
        self.clamp_fee_contribution = false;
3✔
201
        self.min_fee_rate = min_fee_rate;
3✔
202
        self.build()
3✔
203
    }
3✔
204

205
    fn build(self) -> Result<Sender, CreateRequestError> {
11✔
206
        let mut psbt =
11✔
207
            self.psbt.validate().map_err(InternalCreateRequestError::InconsistentOriginalPsbt)?;
11✔
208
        psbt.validate_input_utxos(true)
11✔
209
            .map_err(InternalCreateRequestError::InvalidOriginalInput)?;
11✔
210
        let endpoint = self.uri.extras.endpoint.clone();
11✔
211
        let disable_output_substitution =
11✔
212
            self.uri.extras.disable_output_substitution || self.disable_output_substitution;
11✔
213
        let payee = self.uri.address.script_pubkey();
11✔
214

11✔
215
        check_single_payee(&psbt, &payee, self.uri.amount)?;
11✔
216
        let fee_contribution = determine_fee_contribution(
11✔
217
            &psbt,
11✔
218
            &payee,
11✔
219
            self.fee_contribution,
11✔
220
            self.clamp_fee_contribution,
11✔
221
        )?;
11✔
222
        clear_unneeded_fields(&mut psbt);
11✔
223

11✔
224
        Ok(Sender {
11✔
225
            psbt,
11✔
226
            endpoint,
11✔
227
            disable_output_substitution,
11✔
228
            fee_contribution,
11✔
229
            payee,
11✔
230
            min_fee_rate: self.min_fee_rate,
11✔
231
        })
11✔
232
    }
11✔
233
}
234

235
#[derive(Clone, PartialEq, Eq)]
236
#[cfg_attr(feature = "v2", derive(Serialize, Deserialize))]
237
pub struct Sender {
238
    /// The original PSBT.
239
    psbt: Psbt,
240
    /// The payjoin directory subdirectory to send the request to.
241
    endpoint: Url,
242
    /// Disallow reciever to substitute original outputs.
243
    disable_output_substitution: bool,
244
    /// (maxadditionalfeecontribution, additionalfeeoutputindex)
245
    fee_contribution: Option<(bitcoin::Amount, usize)>,
246
    min_fee_rate: FeeRate,
247
    /// Script of the person being paid
248
    payee: ScriptBuf,
249
}
250

251
impl Sender {
252
    /// Extract serialized V1 Request and Context from a Payjoin Proposal
253
    pub fn extract_v1(&self) -> Result<(Request, V1Context), CreateRequestError> {
8✔
254
        let url = serialize_url(
8✔
255
            self.endpoint.clone(),
8✔
256
            self.disable_output_substitution,
8✔
257
            self.fee_contribution,
8✔
258
            self.min_fee_rate,
8✔
259
            "1", // payjoin version
8✔
260
        )
8✔
261
        .map_err(InternalCreateRequestError::Url)?;
8✔
262
        let body = self.psbt.to_string().as_bytes().to_vec();
8✔
263
        Ok((
8✔
264
            Request::new_v1(url, body),
8✔
265
            V1Context {
8✔
266
                psbt_context: PsbtContext {
8✔
267
                    original_psbt: self.psbt.clone(),
8✔
268
                    disable_output_substitution: self.disable_output_substitution,
8✔
269
                    fee_contribution: self.fee_contribution,
8✔
270
                    payee: self.payee.clone(),
8✔
271
                    min_fee_rate: self.min_fee_rate,
8✔
272
                    allow_mixed_input_scripts: false,
8✔
273
                },
8✔
274
            },
8✔
275
        ))
8✔
276
    }
8✔
277

278
    /// Extract serialized Request and Context from a Payjoin Proposal.
279
    ///
280
    /// This method requires the `rs` pubkey to be extracted from the endpoint
281
    /// and has no fallback to v1.
282
    #[cfg(feature = "v2")]
283
    pub fn extract_v2(
4✔
284
        &self,
4✔
285
        ohttp_relay: Url,
4✔
286
    ) -> Result<(Request, V2PostContext), CreateRequestError> {
4✔
287
        use crate::uri::UrlExt;
288
        if let Ok(expiry) = self.endpoint.exp() {
4✔
289
            if std::time::SystemTime::now() > expiry {
4✔
290
                return Err(InternalCreateRequestError::Expired(expiry).into());
1✔
291
            }
3✔
292
        }
×
293
        let rs = self.extract_rs_pubkey()?;
3✔
294
        let url = self.endpoint.clone();
3✔
295
        let body = serialize_v2_body(
3✔
296
            &self.psbt,
3✔
297
            self.disable_output_substitution,
3✔
298
            self.fee_contribution,
3✔
299
            self.min_fee_rate,
3✔
300
        )?;
3✔
301
        let hpke_ctx = HpkeContext::new(rs);
3✔
302
        let body = encrypt_message_a(
3✔
303
            body,
3✔
304
            &hpke_ctx.reply_pair.public_key().clone(),
3✔
305
            &hpke_ctx.receiver.clone(),
3✔
306
        )
3✔
307
        .map_err(InternalCreateRequestError::Hpke)?;
3✔
308
        let mut ohttp =
3✔
309
            self.endpoint.ohttp().map_err(|_| InternalCreateRequestError::MissingOhttpConfig)?;
3✔
310
        let (body, ohttp_ctx) = ohttp_encapsulate(&mut ohttp, "POST", url.as_str(), Some(&body))
3✔
311
            .map_err(InternalCreateRequestError::OhttpEncapsulation)?;
3✔
312
        log::debug!("ohttp_relay_url: {:?}", ohttp_relay);
3✔
313
        Ok((
3✔
314
            Request::new_v2(ohttp_relay, body),
3✔
315
            V2PostContext {
3✔
316
                endpoint: self.endpoint.clone(),
3✔
317
                psbt_ctx: PsbtContext {
3✔
318
                    original_psbt: self.psbt.clone(),
3✔
319
                    disable_output_substitution: self.disable_output_substitution,
3✔
320
                    fee_contribution: self.fee_contribution,
3✔
321
                    payee: self.payee.clone(),
3✔
322
                    min_fee_rate: self.min_fee_rate,
3✔
323
                    allow_mixed_input_scripts: true,
3✔
324
                },
3✔
325
                hpke_ctx,
3✔
326
                ohttp_ctx,
3✔
327
            },
3✔
328
        ))
3✔
329
    }
4✔
330

331
    #[cfg(feature = "v2")]
332
    fn extract_rs_pubkey(
3✔
333
        &self,
3✔
334
    ) -> Result<HpkePublicKey, crate::uri::url_ext::ParseReceiverPubkeyParamError> {
3✔
335
        use crate::uri::UrlExt;
336
        self.endpoint.receiver_pubkey()
3✔
337
    }
3✔
338

339
    pub fn endpoint(&self) -> &Url { &self.endpoint }
×
340
}
341

342
/// Data required to validate the response.
343
///
344
/// This type is used to process the response. Get it from [`Sender`]'s build methods.
345
/// Then call [`Self::process_response`] on it to continue BIP78 flow.
346
#[derive(Debug, Clone)]
347
pub struct V1Context {
348
    psbt_context: PsbtContext,
349
}
350

351
impl V1Context {
352
    pub fn process_response(
7✔
353
        self,
7✔
354
        response: &mut impl std::io::Read,
7✔
355
    ) -> Result<Psbt, ResponseError> {
7✔
356
        self.psbt_context.process_response(response)
7✔
357
    }
7✔
358
}
359

360
#[cfg(feature = "v2")]
361
pub struct V2PostContext {
362
    /// The payjoin directory subdirectory to send the request to.
363
    endpoint: Url,
364
    psbt_ctx: PsbtContext,
365
    hpke_ctx: HpkeContext,
366
    ohttp_ctx: ohttp::ClientResponse,
367
}
368

369
#[cfg(feature = "v2")]
370
impl V2PostContext {
371
    pub fn process_response(self, response: &[u8]) -> Result<V2GetContext, ResponseError> {
3✔
372
        let response_array: &[u8; crate::ohttp::ENCAPSULATED_MESSAGE_BYTES] =
3✔
373
            response
3✔
374
                .try_into()
3✔
375
                .map_err(|_| InternalValidationError::UnexpectedResponseSize(response.len()))?;
3✔
376
        let response = ohttp_decapsulate(self.ohttp_ctx, response_array)
3✔
377
            .map_err(InternalValidationError::OhttpEncapsulation)?;
3✔
378
        match response.status() {
3✔
379
            http::StatusCode::OK => {
380
                // return OK with new Typestate
381
                Ok(V2GetContext {
3✔
382
                    endpoint: self.endpoint,
3✔
383
                    psbt_ctx: self.psbt_ctx,
3✔
384
                    hpke_ctx: self.hpke_ctx,
3✔
385
                })
3✔
386
            }
387
            _ => Err(InternalValidationError::UnexpectedStatusCode)?,
×
388
        }
389
    }
3✔
390
}
391

392
#[cfg(feature = "v2")]
393
#[derive(Debug, Clone)]
394
pub struct V2GetContext {
395
    /// The payjoin directory subdirectory to send the request to.
396
    endpoint: Url,
397
    psbt_ctx: PsbtContext,
398
    hpke_ctx: HpkeContext,
399
}
400

401
#[cfg(feature = "v2")]
402
impl V2GetContext {
403
    pub fn extract_req(
8✔
404
        &self,
8✔
405
        ohttp_relay: Url,
8✔
406
    ) -> Result<(Request, ohttp::ClientResponse), CreateRequestError> {
8✔
407
        use crate::uri::UrlExt;
408
        let mut url = self.endpoint.clone();
8✔
409

8✔
410
        // TODO unify with receiver's fn subdir_path_from_pubkey
8✔
411
        let hash = sha256::Hash::hash(&self.hpke_ctx.reply_pair.public_key().to_compressed_bytes());
8✔
412
        let subdir: ShortId = hash.into();
8✔
413
        url.set_path(&subdir.to_string());
8✔
414
        let body = encrypt_message_a(
8✔
415
            Vec::new(),
8✔
416
            &self.hpke_ctx.reply_pair.public_key().clone(),
8✔
417
            &self.hpke_ctx.receiver.clone(),
8✔
418
        )
8✔
419
        .map_err(InternalCreateRequestError::Hpke)?;
8✔
420
        let mut ohttp =
8✔
421
            self.endpoint.ohttp().map_err(|_| InternalCreateRequestError::MissingOhttpConfig)?;
8✔
422
        let (body, ohttp_ctx) = ohttp_encapsulate(&mut ohttp, "GET", url.as_str(), Some(&body))
8✔
423
            .map_err(InternalCreateRequestError::OhttpEncapsulation)?;
8✔
424

425
        Ok((Request::new_v2(ohttp_relay, body), ohttp_ctx))
8✔
426
    }
8✔
427

428
    pub fn process_response(
8✔
429
        &self,
8✔
430
        response: &[u8],
8✔
431
        ohttp_ctx: ohttp::ClientResponse,
8✔
432
    ) -> Result<Option<Psbt>, ResponseError> {
8✔
433
        let response_array: &[u8; crate::ohttp::ENCAPSULATED_MESSAGE_BYTES] =
7✔
434
            response
8✔
435
                .try_into()
8✔
436
                .map_err(|_| InternalValidationError::UnexpectedResponseSize(response.len()))?;
8✔
437

438
        let response = ohttp_decapsulate(ohttp_ctx, response_array)
7✔
439
            .map_err(InternalValidationError::OhttpEncapsulation)?;
7✔
440
        let body = match response.status() {
7✔
441
            http::StatusCode::OK => response.body().to_vec(),
2✔
442
            http::StatusCode::ACCEPTED => return Ok(None),
5✔
443
            _ => return Err(InternalValidationError::UnexpectedStatusCode)?,
×
444
        };
445
        let psbt = decrypt_message_b(
2✔
446
            &body,
2✔
447
            self.hpke_ctx.receiver.clone(),
2✔
448
            self.hpke_ctx.reply_pair.secret_key().clone(),
2✔
449
        )
2✔
450
        .map_err(InternalValidationError::Hpke)?;
2✔
451

452
        let proposal = Psbt::deserialize(&psbt).map_err(InternalValidationError::Psbt)?;
2✔
453
        let processed_proposal = self.psbt_ctx.clone().process_proposal(proposal)?;
2✔
454
        Ok(Some(processed_proposal))
2✔
455
    }
8✔
456
}
457

458
/// Data required to validate the response against the original PSBT.
459
#[derive(Debug, Clone)]
460
pub struct PsbtContext {
461
    original_psbt: Psbt,
462
    disable_output_substitution: bool,
463
    fee_contribution: Option<(bitcoin::Amount, usize)>,
464
    min_fee_rate: FeeRate,
465
    payee: ScriptBuf,
466
    allow_mixed_input_scripts: bool,
467
}
468

469
#[cfg(feature = "v2")]
470
#[derive(Debug, Clone)]
471
struct HpkeContext {
472
    receiver: HpkePublicKey,
473
    reply_pair: HpkeKeyPair,
474
}
475

476
#[cfg(feature = "v2")]
477
impl HpkeContext {
478
    pub fn new(receiver: HpkePublicKey) -> Self {
3✔
479
        Self { receiver, reply_pair: HpkeKeyPair::gen_keypair() }
3✔
480
    }
3✔
481
}
482

483
macro_rules! check_eq {
484
    ($proposed:expr, $original:expr, $error:ident) => {
485
        match ($proposed, $original) {
486
            (proposed, original) if proposed != original =>
487
                return Err(InternalValidationError::$error { proposed, original }),
488
            _ => (),
489
        }
490
    };
491
}
492

493
macro_rules! ensure {
494
    ($cond:expr, $error:ident) => {
495
        if !($cond) {
496
            return Err(InternalValidationError::$error);
497
        }
498
    };
499
}
500

501
impl PsbtContext {
502
    /// Decodes and validates the response.
503
    ///
504
    /// Call this method with response from receiver to continue BIP78 flow. If the response is
505
    /// valid you will get appropriate PSBT that you should sign and broadcast.
506
    #[inline]
507
    pub fn process_response(
11✔
508
        self,
11✔
509
        response: &mut impl std::io::Read,
11✔
510
    ) -> Result<Psbt, ResponseError> {
11✔
511
        let mut res_str = String::new();
11✔
512
        response.read_to_string(&mut res_str).map_err(InternalValidationError::Io)?;
11✔
513
        let proposal = Psbt::from_str(&res_str).map_err(|_| ResponseError::parse(&res_str))?;
11✔
514
        self.process_proposal(proposal).map(Into::into).map_err(Into::into)
7✔
515
    }
11✔
516

517
    fn process_proposal(self, mut proposal: Psbt) -> InternalResult<Psbt> {
13✔
518
        self.basic_checks(&proposal)?;
13✔
519
        self.check_inputs(&proposal)?;
13✔
520
        let contributed_fee = self.check_outputs(&proposal)?;
13✔
521
        self.restore_original_utxos(&mut proposal)?;
11✔
522
        self.check_fees(&proposal, contributed_fee)?;
11✔
523
        Ok(proposal)
11✔
524
    }
13✔
525

526
    fn check_fees(&self, proposal: &Psbt, contributed_fee: Amount) -> InternalResult<()> {
11✔
527
        let proposed_fee = proposal.fee().map_err(InternalValidationError::Psbt)?;
11✔
528
        let original_fee = self.original_psbt.fee().map_err(InternalValidationError::Psbt)?;
11✔
529
        ensure!(original_fee <= proposed_fee, AbsoluteFeeDecreased);
11✔
530
        ensure!(contributed_fee <= proposed_fee - original_fee, PayeeTookContributedFee);
11✔
531
        let original_weight = self.original_psbt.clone().extract_tx_unchecked_fee_rate().weight();
11✔
532
        let original_fee_rate = original_fee / original_weight;
11✔
533
        let original_spks = self
11✔
534
            .original_psbt
11✔
535
            .input_pairs()
11✔
536
            .map(|input_pair| {
11✔
537
                input_pair
11✔
538
                    .previous_txout()
11✔
539
                    .map_err(InternalValidationError::PrevTxOut)
11✔
540
                    .map(|txout| txout.script_pubkey.clone())
11✔
541
            })
11✔
542
            .collect::<InternalResult<Vec<ScriptBuf>>>()?;
11✔
543
        let additional_input_weight = proposal.input_pairs().try_fold(
11✔
544
            Weight::ZERO,
11✔
545
            |acc, input_pair| -> InternalResult<Weight> {
122✔
546
                let spk = &input_pair
122✔
547
                    .previous_txout()
122✔
548
                    .map_err(InternalValidationError::PrevTxOut)?
122✔
549
                    .script_pubkey;
550
                if original_spks.contains(spk) {
122✔
551
                    Ok(acc)
11✔
552
                } else {
553
                    let weight = input_pair
111✔
554
                        .expected_input_weight()
111✔
555
                        .map_err(InternalValidationError::InputWeight)?;
111✔
556
                    Ok(acc + weight)
111✔
557
                }
558
            },
122✔
559
        )?;
11✔
560
        ensure!(
11✔
561
            contributed_fee <= original_fee_rate * additional_input_weight,
11✔
562
            FeeContributionPaysOutputSizeIncrease
11✔
563
        );
11✔
564
        if self.min_fee_rate > FeeRate::ZERO {
11✔
565
            let proposed_weight = proposal.clone().extract_tx_unchecked_fee_rate().weight();
3✔
566
            ensure!(proposed_fee / proposed_weight >= self.min_fee_rate, FeeRateBelowMinimum);
3✔
567
        }
8✔
568
        Ok(())
11✔
569
    }
11✔
570

571
    /// Check that the version and lock time are the same as in the original PSBT.
572
    fn basic_checks(&self, proposal: &Psbt) -> InternalResult<()> {
13✔
573
        check_eq!(
13✔
574
            proposal.unsigned_tx.version,
13✔
575
            self.original_psbt.unsigned_tx.version,
13✔
576
            VersionsDontMatch
13✔
577
        );
13✔
578
        check_eq!(
13✔
579
            proposal.unsigned_tx.lock_time,
13✔
580
            self.original_psbt.unsigned_tx.lock_time,
13✔
581
            LockTimesDontMatch
13✔
582
        );
13✔
583
        Ok(())
13✔
584
    }
13✔
585

586
    fn check_inputs(&self, proposal: &Psbt) -> InternalResult<()> {
13✔
587
        let mut original_inputs = self.original_psbt.input_pairs().peekable();
13✔
588

589
        for proposed in proposal.input_pairs() {
126✔
590
            ensure!(proposed.psbtin.bip32_derivation.is_empty(), TxInContainsKeyPaths);
126✔
591
            ensure!(proposed.psbtin.partial_sigs.is_empty(), ContainsPartialSigs);
126✔
592
            match original_inputs.peek() {
126✔
593
                // our (sender)
594
                Some(original)
13✔
595
                    if proposed.txin.previous_output == original.txin.previous_output =>
116✔
596
                {
13✔
597
                    check_eq!(
13✔
598
                        proposed.txin.sequence,
13✔
599
                        original.txin.sequence,
13✔
600
                        SenderTxinSequenceChanged
13✔
601
                    );
13✔
602
                    ensure!(
13✔
603
                        proposed.psbtin.non_witness_utxo.is_none(),
13✔
604
                        SenderTxinContainsNonWitnessUtxo
13✔
605
                    );
13✔
606
                    ensure!(proposed.psbtin.witness_utxo.is_none(), SenderTxinContainsWitnessUtxo);
13✔
607
                    ensure!(
13✔
608
                        proposed.psbtin.final_script_sig.is_none(),
13✔
609
                        SenderTxinContainsFinalScriptSig
13✔
610
                    );
13✔
611
                    ensure!(
13✔
612
                        proposed.psbtin.final_script_witness.is_none(),
13✔
613
                        SenderTxinContainsFinalScriptWitness
13✔
614
                    );
13✔
615
                    original_inputs.next();
13✔
616
                }
617
                // theirs (receiver)
618
                None | Some(_) => {
619
                    let original = self
113✔
620
                        .original_psbt
113✔
621
                        .input_pairs()
113✔
622
                        .next()
113✔
623
                        .ok_or(InternalValidationError::NoInputs)?;
113✔
624
                    // Verify the PSBT input is finalized
625
                    ensure!(
113✔
626
                        proposed.psbtin.final_script_sig.is_some()
113✔
627
                            || proposed.psbtin.final_script_witness.is_some(),
105✔
628
                        ReceiverTxinNotFinalized
629
                    );
630
                    // Verify that non_witness_utxo or witness_utxo are filled in.
631
                    ensure!(
113✔
632
                        proposed.psbtin.witness_utxo.is_some()
113✔
633
                            || proposed.psbtin.non_witness_utxo.is_some(),
×
634
                        ReceiverTxinMissingUtxoInfo
635
                    );
636
                    ensure!(proposed.txin.sequence == original.txin.sequence, MixedSequence);
113✔
637
                    if !self.allow_mixed_input_scripts {
113✔
638
                        check_eq!(
109✔
639
                            proposed.address_type()?,
109✔
640
                            original.address_type()?,
109✔
641
                            MixedInputTypes
642
                        );
643
                    }
4✔
644
                }
645
            }
646
        }
647
        ensure!(original_inputs.peek().is_none(), MissingOrShuffledInputs);
13✔
648
        Ok(())
13✔
649
    }
13✔
650

651
    /// Restore Original PSBT utxos that the receiver stripped.
652
    /// The BIP78 spec requires utxo information to be removed, but many wallets
653
    /// require it to be present to sign.
654
    fn restore_original_utxos(&self, proposal: &mut Psbt) -> InternalResult<()> {
11✔
655
        let mut original_inputs = self.original_psbt.input_pairs().peekable();
11✔
656
        let proposal_inputs =
11✔
657
            proposal.unsigned_tx.input.iter().zip(&mut proposal.inputs).peekable();
11✔
658

659
        for (proposed_txin, proposed_psbtin) in proposal_inputs {
133✔
660
            if let Some(original) = original_inputs.peek() {
122✔
661
                if proposed_txin.previous_output == original.txin.previous_output {
114✔
662
                    proposed_psbtin.non_witness_utxo = original.psbtin.non_witness_utxo.clone();
11✔
663
                    proposed_psbtin.witness_utxo = original.psbtin.witness_utxo.clone();
11✔
664
                    proposed_psbtin.bip32_derivation = original.psbtin.bip32_derivation.clone();
11✔
665
                    proposed_psbtin.tap_internal_key = original.psbtin.tap_internal_key;
11✔
666
                    proposed_psbtin.tap_key_origins = original.psbtin.tap_key_origins.clone();
11✔
667
                    original_inputs.next();
11✔
668
                }
103✔
669
            }
8✔
670
        }
671
        Ok(())
11✔
672
    }
11✔
673

674
    fn check_outputs(&self, proposal: &Psbt) -> InternalResult<Amount> {
13✔
675
        let mut original_outputs =
13✔
676
            self.original_psbt.unsigned_tx.output.iter().enumerate().peekable();
13✔
677
        let mut contributed_fee = Amount::ZERO;
13✔
678

679
        for (proposed_txout, proposed_psbtout) in
23✔
680
            proposal.unsigned_tx.output.iter().zip(&proposal.outputs)
13✔
681
        {
682
            ensure!(proposed_psbtout.bip32_derivation.is_empty(), TxOutContainsKeyPaths);
23✔
683
            match (original_outputs.peek(), self.fee_contribution) {
23✔
684
                // fee output
685
                (
686
                    Some((original_output_index, original_output)),
20✔
687
                    Some((max_fee_contrib, fee_contrib_idx)),
11✔
688
                ) if proposed_txout.script_pubkey == original_output.script_pubkey
20✔
689
                    && *original_output_index == fee_contrib_idx =>
14✔
690
                {
11✔
691
                    if proposed_txout.value < original_output.value {
11✔
692
                        contributed_fee = original_output.value - proposed_txout.value;
10✔
693
                        ensure!(contributed_fee <= max_fee_contrib, FeeContributionExceedsMaximum);
10✔
694
                        // The remaining fee checks are done in later in `check_fees`
695
                    }
1✔
696
                    original_outputs.next();
9✔
697
                }
698
                // payee output
699
                (Some((_original_output_index, original_output)), _)
11✔
700
                    if original_output.script_pubkey == self.payee =>
11✔
701
                {
11✔
702
                    ensure!(
11✔
703
                        !self.disable_output_substitution
11✔
704
                            || (proposed_txout.script_pubkey == original_output.script_pubkey
×
705
                                && proposed_txout.value >= original_output.value),
×
706
                        DisallowedOutputSubstitution
707
                    );
708
                    original_outputs.next();
11✔
709
                }
710
                // our output
UNCOV
711
                (Some((_original_output_index, original_output)), _)
×
UNCOV
712
                    if proposed_txout.script_pubkey == original_output.script_pubkey =>
×
713
                {
×
714
                    ensure!(proposed_txout.value >= original_output.value, OutputValueDecreased);
×
715
                    original_outputs.next();
×
716
                }
717
                // additional output
718
                _ => (),
1✔
719
            }
720
        }
721

722
        ensure!(original_outputs.peek().is_none(), MissingOrShuffledOutputs);
11✔
723
        Ok(contributed_fee)
11✔
724
    }
13✔
725
}
726

727
/// Ensure that the payee's output scriptPubKey appears in the list of outputs exactly once,
728
/// and that the payee's output amount matches the requested amount.
729
fn check_single_payee(
11✔
730
    psbt: &Psbt,
11✔
731
    script_pubkey: &Script,
11✔
732
    amount: Option<bitcoin::Amount>,
11✔
733
) -> Result<(), InternalCreateRequestError> {
11✔
734
    let mut payee_found = false;
11✔
735
    for output in &psbt.unsigned_tx.output {
31✔
736
        if output.script_pubkey == *script_pubkey {
20✔
737
            if let Some(amount) = amount {
11✔
738
                if output.value != amount {
7✔
739
                    return Err(InternalCreateRequestError::PayeeValueNotEqual);
×
740
                }
7✔
741
            }
4✔
742
            if payee_found {
11✔
743
                return Err(InternalCreateRequestError::MultiplePayeeOutputs);
×
744
            }
11✔
745
            payee_found = true;
11✔
746
        }
9✔
747
    }
748
    if payee_found {
11✔
749
        Ok(())
11✔
750
    } else {
751
        Err(InternalCreateRequestError::MissingPayeeOutput)
×
752
    }
753
}
11✔
754

755
fn clear_unneeded_fields(psbt: &mut Psbt) {
11✔
756
    psbt.xpub_mut().clear();
11✔
757
    psbt.proprietary_mut().clear();
11✔
758
    psbt.unknown_mut().clear();
11✔
759
    for input in psbt.inputs_mut() {
11✔
760
        input.bip32_derivation.clear();
11✔
761
        input.tap_internal_key = None;
11✔
762
        input.tap_key_origins.clear();
11✔
763
        input.tap_key_sig = None;
11✔
764
        input.tap_merkle_root = None;
11✔
765
        input.tap_script_sigs.clear();
11✔
766
        input.proprietary.clear();
11✔
767
        input.unknown.clear();
11✔
768
    }
11✔
769
    for output in psbt.outputs_mut() {
20✔
770
        output.bip32_derivation.clear();
20✔
771
        output.tap_internal_key = None;
20✔
772
        output.tap_key_origins.clear();
20✔
773
        output.proprietary.clear();
20✔
774
        output.unknown.clear();
20✔
775
    }
20✔
776
}
11✔
777

778
/// Ensure that an additional fee output is sufficient to pay for the specified additional fee
779
fn check_fee_output_amount(
8✔
780
    output: &TxOut,
8✔
781
    fee: bitcoin::Amount,
8✔
782
    clamp_fee_contribution: bool,
8✔
783
) -> Result<bitcoin::Amount, InternalCreateRequestError> {
8✔
784
    if output.value < fee {
8✔
785
        if clamp_fee_contribution {
×
786
            Ok(output.value)
×
787
        } else {
788
            Err(InternalCreateRequestError::FeeOutputValueLowerThanFeeContribution)
×
789
        }
790
    } else {
791
        Ok(fee)
8✔
792
    }
793
}
8✔
794

795
/// Find the sender's change output index by eliminating the payee's output as a candidate.
796
fn find_change_index(
7✔
797
    psbt: &Psbt,
7✔
798
    payee: &Script,
7✔
799
    fee: bitcoin::Amount,
7✔
800
    clamp_fee_contribution: bool,
7✔
801
) -> Result<Option<(bitcoin::Amount, usize)>, InternalCreateRequestError> {
7✔
802
    match (psbt.unsigned_tx.output.len(), clamp_fee_contribution) {
7✔
803
        (0, _) => return Err(InternalCreateRequestError::NoOutputs),
×
804
        (1, false) if psbt.unsigned_tx.output[0].script_pubkey == *payee =>
×
805
            return Err(InternalCreateRequestError::FeeOutputValueLowerThanFeeContribution),
×
806
        (1, true) if psbt.unsigned_tx.output[0].script_pubkey == *payee => return Ok(None),
×
807
        (1, _) => return Err(InternalCreateRequestError::MissingPayeeOutput),
×
808
        (2, _) => (),
7✔
809
        _ => return Err(InternalCreateRequestError::AmbiguousChangeOutput),
×
810
    }
811
    let (index, output) = psbt
7✔
812
        .unsigned_tx
7✔
813
        .output
7✔
814
        .iter()
7✔
815
        .enumerate()
7✔
816
        .find(|(_, output)| output.script_pubkey != *payee)
10✔
817
        .ok_or(InternalCreateRequestError::MultiplePayeeOutputs)?;
7✔
818

819
    Ok(Some((check_fee_output_amount(output, fee, clamp_fee_contribution)?, index)))
7✔
820
}
7✔
821

822
/// Check that the change output index is not out of bounds
823
/// and that the additional fee contribution is not less than specified.
824
fn check_change_index(
1✔
825
    psbt: &Psbt,
1✔
826
    payee: &Script,
1✔
827
    fee: bitcoin::Amount,
1✔
828
    index: usize,
1✔
829
    clamp_fee_contribution: bool,
1✔
830
) -> Result<(bitcoin::Amount, usize), InternalCreateRequestError> {
1✔
831
    let output = psbt
1✔
832
        .unsigned_tx
1✔
833
        .output
1✔
834
        .get(index)
1✔
835
        .ok_or(InternalCreateRequestError::ChangeIndexOutOfBounds)?;
1✔
836
    if output.script_pubkey == *payee {
1✔
837
        return Err(InternalCreateRequestError::ChangeIndexPointsAtPayee);
×
838
    }
1✔
839
    Ok((check_fee_output_amount(output, fee, clamp_fee_contribution)?, index))
1✔
840
}
1✔
841

842
fn determine_fee_contribution(
11✔
843
    psbt: &Psbt,
11✔
844
    payee: &Script,
11✔
845
    fee_contribution: Option<(bitcoin::Amount, Option<usize>)>,
11✔
846
    clamp_fee_contribution: bool,
11✔
847
) -> Result<Option<(bitcoin::Amount, usize)>, InternalCreateRequestError> {
11✔
848
    Ok(match fee_contribution {
8✔
849
        Some((fee, None)) => find_change_index(psbt, payee, fee, clamp_fee_contribution)?,
7✔
850
        Some((fee, Some(index))) =>
1✔
851
            Some(check_change_index(psbt, payee, fee, index, clamp_fee_contribution)?),
1✔
852
        None => None,
3✔
853
    })
854
}
11✔
855

856
#[cfg(feature = "v2")]
857
fn serialize_v2_body(
3✔
858
    psbt: &Psbt,
3✔
859
    disable_output_substitution: bool,
3✔
860
    fee_contribution: Option<(bitcoin::Amount, usize)>,
3✔
861
    min_feerate: FeeRate,
3✔
862
) -> Result<Vec<u8>, CreateRequestError> {
3✔
863
    // Grug say localhost base be discarded anyway. no big brain needed.
864
    let placeholder_url = serialize_url(
3✔
865
        Url::parse("http://localhost").unwrap(),
3✔
866
        disable_output_substitution,
3✔
867
        fee_contribution,
3✔
868
        min_feerate,
3✔
869
        "2", // payjoin version
3✔
870
    )
3✔
871
    .map_err(InternalCreateRequestError::Url)?;
3✔
872
    let query_params = placeholder_url.query().unwrap_or_default();
3✔
873
    let base64 = psbt.to_string();
3✔
874
    Ok(format!("{}\n{}", base64, query_params).into_bytes())
3✔
875
}
3✔
876

877
fn serialize_url(
11✔
878
    endpoint: Url,
11✔
879
    disable_output_substitution: bool,
11✔
880
    fee_contribution: Option<(bitcoin::Amount, usize)>,
11✔
881
    min_fee_rate: FeeRate,
11✔
882
    version: &str,
11✔
883
) -> Result<Url, url::ParseError> {
11✔
884
    let mut url = endpoint;
11✔
885
    url.query_pairs_mut().append_pair("v", version);
11✔
886
    if disable_output_substitution {
11✔
887
        url.query_pairs_mut().append_pair("disableoutputsubstitution", "1");
×
888
    }
11✔
889
    if let Some((amount, index)) = fee_contribution {
11✔
890
        url.query_pairs_mut()
9✔
891
            .append_pair("additionalfeeoutputindex", &index.to_string())
9✔
892
            .append_pair("maxadditionalfeecontribution", &amount.to_sat().to_string());
9✔
893
    }
9✔
894
    if min_fee_rate > FeeRate::ZERO {
11✔
895
        // TODO serialize in rust-bitcoin <https://github.com/rust-bitcoin/rust-bitcoin/pull/1787/files#diff-c2ea40075e93ccd068673873166cfa3312ec7439d6bc5a4cbc03e972c7e045c4>
4✔
896
        let float_fee_rate = min_fee_rate.to_sat_per_kwu() as f32 / 250.0_f32;
4✔
897
        url.query_pairs_mut().append_pair("minfeerate", &float_fee_rate.to_string());
4✔
898
    }
7✔
899
    Ok(url)
11✔
900
}
11✔
901

902
#[cfg(test)]
903
mod test {
904
    use std::str::FromStr;
905

906
    use bitcoin::psbt::Psbt;
907
    use bitcoin::FeeRate;
908

909
    use crate::psbt::PsbtExt;
910
    use crate::send::error::{ResponseError, WellKnownError};
911

912
    const ORIGINAL_PSBT: &str = "cHNidP8BAHMCAAAAAY8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////AtyVuAUAAAAAF6kUHehJ8GnSdBUOOv6ujXLrWmsJRDCHgIQeAAAAAAAXqRR3QJbbz0hnQ8IvQ0fptGn+votneofTAAAAAAEBIKgb1wUAAAAAF6kU3k4ekGHKWRNbA1rV5tR5kEVDVNCHAQcXFgAUx4pFclNVgo1WWAdN1SYNX8tphTABCGsCRzBEAiB8Q+A6dep+Rz92vhy26lT0AjZn4PRLi8Bf9qoB/CMk0wIgP/Rj2PWZ3gEjUkTlhDRNAQ0gXwTO7t9n+V14pZ6oljUBIQMVmsAaoNWHVMS02LfTSe0e388LNitPa1UQZyOihY+FFgABABYAFEb2Giu6c4KO5YW0pfw3lGp9jMUUAAA=";
913
    const PAYJOIN_PROPOSAL: &str = "cHNidP8BAJwCAAAAAo8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////jye60aAl3JgZdaIERvjkeh72VYZuTGH/ps2I4l0IO4MBAAAAAP7///8CJpW4BQAAAAAXqRQd6EnwadJ0FQ46/q6NcutaawlEMIcACT0AAAAAABepFHdAltvPSGdDwi9DR+m0af6+i2d6h9MAAAAAAQEgqBvXBQAAAAAXqRTeTh6QYcpZE1sDWtXm1HmQRUNU0IcBBBYAFMeKRXJTVYKNVlgHTdUmDV/LaYUwIgYDFZrAGqDVh1TEtNi300ntHt/PCzYrT2tVEGcjooWPhRYYSFzWUDEAAIABAACAAAAAgAEAAAAAAAAAAAEBIICEHgAAAAAAF6kUyPLL+cphRyyI5GTUazV0hF2R2NWHAQcXFgAUX4BmVeWSTJIEwtUb5TlPS/ntohABCGsCRzBEAiBnu3tA3yWlT0WBClsXXS9j69Bt+waCs9JcjWtNjtv7VgIge2VYAaBeLPDB6HGFlpqOENXMldsJezF9Gs5amvDQRDQBIQJl1jz1tBt8hNx2owTm+4Du4isx0pmdKNMNIjjaMHFfrQABABYAFEb2Giu6c4KO5YW0pfw3lGp9jMUUIgICygvBWB5prpfx61y1HDAwo37kYP3YRJBvAjtunBAur3wYSFzWUDEAAIABAACAAAAAgAEAAAABAAAAAAA=";
914

915
    fn create_v1_context() -> super::PsbtContext {
8✔
916
        let original_psbt = Psbt::from_str(ORIGINAL_PSBT).unwrap();
8✔
917
        eprintln!("original: {:#?}", original_psbt);
8✔
918
        let payee = original_psbt.unsigned_tx.output[1].script_pubkey.clone();
8✔
919
        super::PsbtContext {
8✔
920
            original_psbt,
8✔
921
            disable_output_substitution: false,
8✔
922
            fee_contribution: Some((bitcoin::Amount::from_sat(182), 0)),
8✔
923
            min_fee_rate: FeeRate::ZERO,
8✔
924
            payee,
8✔
925
            allow_mixed_input_scripts: false,
8✔
926
        }
8✔
927
    }
8✔
928

929
    #[test]
930
    fn official_vectors() {
2✔
931
        let original_psbt = Psbt::from_str(ORIGINAL_PSBT).unwrap();
2✔
932
        eprintln!("original: {:#?}", original_psbt);
2✔
933
        let ctx = create_v1_context();
2✔
934
        let mut proposal = Psbt::from_str(PAYJOIN_PROPOSAL).unwrap();
2✔
935
        eprintln!("proposal: {:#?}", proposal);
2✔
936
        for output in proposal.outputs_mut() {
4✔
937
            output.bip32_derivation.clear();
4✔
938
        }
4✔
939
        for input in proposal.inputs_mut() {
4✔
940
            input.bip32_derivation.clear();
4✔
941
        }
4✔
942
        proposal.inputs_mut()[0].witness_utxo = None;
2✔
943
        ctx.process_proposal(proposal).unwrap();
2✔
944
    }
2✔
945

946
    #[test]
947
    #[should_panic]
948
    fn test_receiver_steals_sender_change() {
2✔
949
        let original_psbt = Psbt::from_str(ORIGINAL_PSBT).unwrap();
2✔
950
        eprintln!("original: {:#?}", original_psbt);
2✔
951
        let ctx = create_v1_context();
2✔
952
        let mut proposal = Psbt::from_str(PAYJOIN_PROPOSAL).unwrap();
2✔
953
        eprintln!("proposal: {:#?}", proposal);
2✔
954
        for output in proposal.outputs_mut() {
4✔
955
            output.bip32_derivation.clear();
4✔
956
        }
4✔
957
        for input in proposal.inputs_mut() {
4✔
958
            input.bip32_derivation.clear();
4✔
959
        }
4✔
960
        proposal.inputs_mut()[0].witness_utxo = None;
2✔
961
        // Steal 0.5 BTC from the sender output and add it to the receiver output
2✔
962
        proposal.unsigned_tx.output[0].value -= bitcoin::Amount::from_btc(0.5).unwrap();
2✔
963
        proposal.unsigned_tx.output[1].value += bitcoin::Amount::from_btc(0.5).unwrap();
2✔
964
        ctx.process_proposal(proposal).unwrap();
2✔
965
    }
2✔
966

967
    #[test]
968
    #[cfg(feature = "v2")]
969
    fn req_ctx_ser_de_roundtrip() {
1✔
970
        use super::*;
971
        let req_ctx = Sender {
1✔
972
            psbt: Psbt::from_str(ORIGINAL_PSBT).unwrap(),
1✔
973
            endpoint: Url::parse("http://localhost:1234").unwrap(),
1✔
974
            disable_output_substitution: false,
1✔
975
            fee_contribution: None,
1✔
976
            min_fee_rate: FeeRate::ZERO,
1✔
977
            payee: ScriptBuf::from(vec![0x00]),
1✔
978
        };
1✔
979
        let serialized = serde_json::to_string(&req_ctx).unwrap();
1✔
980
        let deserialized = serde_json::from_str(&serialized).unwrap();
1✔
981
        assert!(req_ctx == deserialized);
1✔
982
    }
1✔
983

984
    #[test]
985
    fn handle_json_errors() {
2✔
986
        let ctx = create_v1_context();
2✔
987
        let known_json_error = serde_json::json!({
2✔
988
            "errorCode": "version-unsupported",
2✔
989
            "message": "This version of payjoin is not supported."
2✔
990
        })
2✔
991
        .to_string();
2✔
992
        match ctx.process_response(&mut known_json_error.as_bytes()) {
2✔
993
            Err(ResponseError::WellKnown(WellKnownError::VersionUnsupported { .. })) => (),
2✔
994
            _ => panic!("Expected WellKnownError"),
×
995
        }
996

997
        let ctx = create_v1_context();
2✔
998
        let invalid_json_error = serde_json::json!({
2✔
999
            "err": "random",
2✔
1000
            "message": "This version of payjoin is not supported."
2✔
1001
        })
2✔
1002
        .to_string();
2✔
1003
        match ctx.process_response(&mut invalid_json_error.as_bytes()) {
2✔
1004
            Err(ResponseError::Validation(_)) => (),
2✔
1005
            _ => panic!("Expected unrecognized JSON error"),
×
1006
        }
1007
    }
2✔
1008
}
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