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

payjoin / rust-payjoin / 13090676415

01 Feb 2025 05:07PM UTC coverage: 78.428% (-0.1%) from 78.576%
13090676415

Pull #520

github

web-flow
Merge a0c8d74c1 into 20620b236
Pull Request #520: Use IntoUrl for more ergonomic function signatures

56 of 86 new or added lines in 7 files covered. (65.12%)

2 existing lines in 1 file now uncovered.

3683 of 4696 relevant lines covered (78.43%)

978.85 hits per line

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

93.91
/payjoin/src/send/mod.rs
1
//! Send Payjoin
2
//!
3
//! This module contains types and methods used to implement sending via Payjoin.
4
//!
5
//! For most use cases, we recommended enabling the `v2` feature, as it is
6
//! backwards compatible and provides the most convenient experience for users and implementors.
7
//! To use version 2, refer to `send::v2` module documentation.
8
//!
9
//! If you specifically need to use
10
//! version 1, refer to the `send::v1` module documentation after enabling the `v1` feature.
11

12
use std::str::FromStr;
13

14
use bitcoin::psbt::Psbt;
15
use bitcoin::{Amount, FeeRate, Script, ScriptBuf, TxOut, Weight};
16
pub use error::{BuildSenderError, ResponseError, ValidationError};
17
pub(crate) use error::{InternalBuildSenderError, InternalProposalError, InternalValidationError};
18
use url::Url;
19

20
use crate::psbt::PsbtExt;
21

22
// See usize casts
23
#[cfg(not(any(target_pointer_width = "32", target_pointer_width = "64")))]
24
compile_error!("This crate currently only supports 32 bit and 64 bit architectures");
25

26
mod error;
27

28
#[cfg(feature = "v1")]
29
#[cfg_attr(docsrs, doc(cfg(feature = "v1")))]
30
pub mod v1;
31
#[cfg(not(feature = "v1"))]
32
pub(crate) mod v1;
33

34
#[cfg(feature = "v2")]
35
#[cfg_attr(docsrs, doc(cfg(feature = "v2")))]
36
pub mod v2;
37

38
type InternalResult<T> = Result<T, InternalProposalError>;
39

40
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41
#[cfg_attr(feature = "v2", derive(serde::Serialize, serde::Deserialize))]
42
pub(crate) struct AdditionalFeeContribution {
43
    max_amount: Amount,
44
    vout: usize,
45
}
46

47
/// Data required to validate the response against the original PSBT.
48
#[derive(Debug, Clone)]
49
pub struct PsbtContext {
50
    original_psbt: Psbt,
51
    disable_output_substitution: bool,
52
    fee_contribution: Option<AdditionalFeeContribution>,
53
    min_fee_rate: FeeRate,
54
    payee: ScriptBuf,
55
}
56

57
macro_rules! check_eq {
58
    ($proposed:expr, $original:expr, $error:ident) => {
59
        match ($proposed, $original) {
60
            (proposed, original) if proposed != original =>
61
                return Err(InternalProposalError::$error { proposed, original }),
62
            _ => (),
63
        }
64
    };
65
}
66

67
macro_rules! ensure {
68
    ($cond:expr, $error:ident) => {
69
        if !($cond) {
70
            return Err(InternalProposalError::$error);
71
        }
72
    };
73
}
74

75
impl PsbtContext {
76
    fn process_proposal(self, mut proposal: Psbt) -> InternalResult<Psbt> {
11✔
77
        self.basic_checks(&proposal)?;
11✔
78
        self.check_inputs(&proposal)?;
11✔
79
        let contributed_fee = self.check_outputs(&proposal)?;
11✔
80
        self.restore_original_utxos(&mut proposal)?;
10✔
81
        self.check_fees(&proposal, contributed_fee)?;
10✔
82
        Ok(proposal)
10✔
83
    }
11✔
84

85
    fn check_fees(&self, proposal: &Psbt, contributed_fee: Amount) -> InternalResult<()> {
10✔
86
        let proposed_fee = proposal.fee().map_err(InternalProposalError::Psbt)?;
10✔
87
        let original_fee = self.original_psbt.fee().map_err(InternalProposalError::Psbt)?;
10✔
88
        ensure!(original_fee <= proposed_fee, AbsoluteFeeDecreased);
10✔
89
        ensure!(contributed_fee <= proposed_fee - original_fee, PayeeTookContributedFee);
10✔
90
        let original_weight = self.original_psbt.clone().extract_tx_unchecked_fee_rate().weight();
10✔
91
        let original_fee_rate = original_fee / original_weight;
10✔
92
        let original_spks = self
10✔
93
            .original_psbt
10✔
94
            .input_pairs()
10✔
95
            .map(|input_pair| {
10✔
96
                input_pair
10✔
97
                    .previous_txout()
10✔
98
                    .map_err(InternalProposalError::PrevTxOut)
10✔
99
                    .map(|txout| txout.script_pubkey.clone())
10✔
100
            })
10✔
101
            .collect::<InternalResult<Vec<ScriptBuf>>>()?;
10✔
102
        let additional_input_weight = proposal.input_pairs().try_fold(
10✔
103
            Weight::ZERO,
10✔
104
            |acc, input_pair| -> InternalResult<Weight> {
118✔
105
                let spk = &input_pair
118✔
106
                    .previous_txout()
118✔
107
                    .map_err(InternalProposalError::PrevTxOut)?
118✔
108
                    .script_pubkey;
109
                if original_spks.contains(spk) {
118✔
110
                    Ok(acc)
10✔
111
                } else {
112
                    let weight = input_pair
108✔
113
                        .expected_input_weight()
108✔
114
                        .map_err(InternalProposalError::InputWeight)?;
108✔
115
                    Ok(acc + weight)
108✔
116
                }
117
            },
118✔
118
        )?;
10✔
119
        ensure!(
10✔
120
            contributed_fee <= original_fee_rate * additional_input_weight,
10✔
121
            FeeContributionPaysOutputSizeIncrease
10✔
122
        );
10✔
123
        if self.min_fee_rate > FeeRate::ZERO {
10✔
124
            let proposed_weight = proposal.clone().extract_tx_unchecked_fee_rate().weight();
3✔
125
            ensure!(proposed_fee / proposed_weight >= self.min_fee_rate, FeeRateBelowMinimum);
3✔
126
        }
7✔
127
        Ok(())
10✔
128
    }
10✔
129

130
    /// Check that the version and lock time are the same as in the original PSBT.
131
    fn basic_checks(&self, proposal: &Psbt) -> InternalResult<()> {
11✔
132
        check_eq!(
11✔
133
            proposal.unsigned_tx.version,
11✔
134
            self.original_psbt.unsigned_tx.version,
11✔
135
            VersionsDontMatch
11✔
136
        );
11✔
137
        check_eq!(
11✔
138
            proposal.unsigned_tx.lock_time,
11✔
139
            self.original_psbt.unsigned_tx.lock_time,
11✔
140
            LockTimesDontMatch
11✔
141
        );
11✔
142
        Ok(())
11✔
143
    }
11✔
144

145
    fn check_inputs(&self, proposal: &Psbt) -> InternalResult<()> {
11✔
146
        let mut original_inputs = self.original_psbt.input_pairs().peekable();
11✔
147

148
        for proposed in proposal.input_pairs() {
120✔
149
            ensure!(proposed.psbtin.bip32_derivation.is_empty(), TxInContainsKeyPaths);
120✔
150
            ensure!(proposed.psbtin.partial_sigs.is_empty(), ContainsPartialSigs);
120✔
151
            match original_inputs.peek() {
120✔
152
                // our (sender)
153
                Some(original)
11✔
154
                    if proposed.txin.previous_output == original.txin.previous_output =>
117✔
155
                {
11✔
156
                    check_eq!(
11✔
157
                        proposed.txin.sequence,
11✔
158
                        original.txin.sequence,
11✔
159
                        SenderTxinSequenceChanged
11✔
160
                    );
11✔
161
                    ensure!(
11✔
162
                        proposed.psbtin.final_script_sig.is_none(),
11✔
163
                        SenderTxinContainsFinalScriptSig
11✔
164
                    );
11✔
165
                    ensure!(
11✔
166
                        proposed.psbtin.final_script_witness.is_none(),
11✔
167
                        SenderTxinContainsFinalScriptWitness
11✔
168
                    );
11✔
169
                    original_inputs.next();
11✔
170
                }
171
                // theirs (receiver)
172
                None | Some(_) => {
173
                    let original = self
109✔
174
                        .original_psbt
109✔
175
                        .input_pairs()
109✔
176
                        .next()
109✔
177
                        .ok_or(InternalProposalError::NoInputs)?;
109✔
178
                    // Verify the PSBT input is finalized
179
                    ensure!(
109✔
180
                        proposed.psbtin.final_script_sig.is_some()
109✔
181
                            || proposed.psbtin.final_script_witness.is_some(),
105✔
182
                        ReceiverTxinNotFinalized
183
                    );
184
                    // Verify that non_witness_utxo or witness_utxo are filled in.
185
                    ensure!(
109✔
186
                        proposed.psbtin.witness_utxo.is_some()
109✔
187
                            || proposed.psbtin.non_witness_utxo.is_some(),
×
188
                        ReceiverTxinMissingUtxoInfo
189
                    );
190
                    ensure!(proposed.txin.sequence == original.txin.sequence, MixedSequence);
109✔
191
                }
192
            }
193
        }
194
        ensure!(original_inputs.peek().is_none(), MissingOrShuffledInputs);
11✔
195
        Ok(())
11✔
196
    }
11✔
197

198
    /// Restore Original PSBT utxos that the receiver stripped.
199
    /// The BIP78 spec requires utxo information to be removed, but many wallets
200
    /// require it to be present to sign.
201
    fn restore_original_utxos(&self, proposal: &mut Psbt) -> InternalResult<()> {
10✔
202
        let mut original_inputs = self.original_psbt.input_pairs().peekable();
10✔
203
        let proposal_inputs =
10✔
204
            proposal.unsigned_tx.input.iter().zip(&mut proposal.inputs).peekable();
10✔
205

206
        for (proposed_txin, proposed_psbtin) in proposal_inputs {
128✔
207
            if let Some(original) = original_inputs.peek() {
118✔
208
                if proposed_txin.previous_output == original.txin.previous_output {
116✔
209
                    proposed_psbtin.non_witness_utxo = original.psbtin.non_witness_utxo.clone();
10✔
210
                    proposed_psbtin.witness_utxo = original.psbtin.witness_utxo.clone();
10✔
211
                    proposed_psbtin.bip32_derivation = original.psbtin.bip32_derivation.clone();
10✔
212
                    proposed_psbtin.tap_internal_key = original.psbtin.tap_internal_key;
10✔
213
                    proposed_psbtin.tap_key_origins = original.psbtin.tap_key_origins.clone();
10✔
214
                    original_inputs.next();
10✔
215
                }
106✔
216
            }
2✔
217
        }
218
        Ok(())
10✔
219
    }
10✔
220

221
    fn check_outputs(&self, proposal: &Psbt) -> InternalResult<Amount> {
11✔
222
        let mut original_outputs =
11✔
223
            self.original_psbt.unsigned_tx.output.iter().enumerate().peekable();
11✔
224
        let mut contributed_fee = Amount::ZERO;
11✔
225

226
        for (proposed_txout, proposed_psbtout) in
21✔
227
            proposal.unsigned_tx.output.iter().zip(&proposal.outputs)
11✔
228
        {
229
            ensure!(proposed_psbtout.bip32_derivation.is_empty(), TxOutContainsKeyPaths);
21✔
230
            match (original_outputs.peek(), self.fee_contribution) {
21✔
231
                // fee output
232
                (
233
                    Some((original_output_index, original_output)),
19✔
234
                    Some(AdditionalFeeContribution {
235
                        max_amount: max_fee_contrib,
10✔
236
                        vout: fee_contrib_idx,
10✔
237
                    }),
238
                ) if proposed_txout.script_pubkey == original_output.script_pubkey
19✔
239
                    && *original_output_index == fee_contrib_idx =>
13✔
240
                {
10✔
241
                    if proposed_txout.value < original_output.value {
10✔
242
                        contributed_fee = original_output.value - proposed_txout.value;
9✔
243
                        ensure!(contributed_fee <= max_fee_contrib, FeeContributionExceedsMaximum);
9✔
244
                        // The remaining fee checks are done in later in `check_fees`
245
                    }
1✔
246
                    original_outputs.next();
9✔
247
                }
248
                // payee output
249
                (Some((_original_output_index, original_output)), _)
10✔
250
                    if original_output.script_pubkey == self.payee =>
10✔
251
                {
10✔
252
                    ensure!(
10✔
253
                        !self.disable_output_substitution
10✔
254
                            || (proposed_txout.script_pubkey == original_output.script_pubkey
×
255
                                && proposed_txout.value >= original_output.value),
×
256
                        DisallowedOutputSubstitution
257
                    );
258
                    original_outputs.next();
10✔
259
                }
260
                // our output
UNCOV
261
                (Some((_original_output_index, original_output)), _)
×
UNCOV
262
                    if proposed_txout.script_pubkey == original_output.script_pubkey =>
×
263
                {
×
264
                    ensure!(proposed_txout.value >= original_output.value, OutputValueDecreased);
×
265
                    original_outputs.next();
×
266
                }
267
                // additional output
268
                _ => (),
1✔
269
            }
270
        }
271

272
        ensure!(original_outputs.peek().is_none(), MissingOrShuffledOutputs);
10✔
273
        Ok(contributed_fee)
10✔
274
    }
11✔
275
}
276

277
/// Ensure that the payee's output scriptPubKey appears in the list of outputs exactly once,
278
/// and that the payee's output amount matches the requested amount.
279
fn check_single_payee(
11✔
280
    psbt: &Psbt,
11✔
281
    script_pubkey: &Script,
11✔
282
    amount: Option<bitcoin::Amount>,
11✔
283
) -> Result<(), InternalBuildSenderError> {
11✔
284
    let mut payee_found = false;
11✔
285
    for output in &psbt.unsigned_tx.output {
32✔
286
        if output.script_pubkey == *script_pubkey {
21✔
287
            if let Some(amount) = amount {
11✔
288
                if output.value != amount {
8✔
289
                    return Err(InternalBuildSenderError::PayeeValueNotEqual);
×
290
                }
8✔
291
            }
3✔
292
            if payee_found {
11✔
293
                return Err(InternalBuildSenderError::MultiplePayeeOutputs);
×
294
            }
11✔
295
            payee_found = true;
11✔
296
        }
10✔
297
    }
298
    if payee_found {
11✔
299
        Ok(())
11✔
300
    } else {
301
        Err(InternalBuildSenderError::MissingPayeeOutput)
×
302
    }
303
}
11✔
304

305
fn clear_unneeded_fields(psbt: &mut Psbt) {
11✔
306
    psbt.xpub_mut().clear();
11✔
307
    psbt.proprietary_mut().clear();
11✔
308
    psbt.unknown_mut().clear();
11✔
309
    for input in psbt.inputs_mut() {
11✔
310
        input.bip32_derivation.clear();
11✔
311
        input.tap_internal_key = None;
11✔
312
        input.tap_key_origins.clear();
11✔
313
        input.tap_key_sig = None;
11✔
314
        input.tap_merkle_root = None;
11✔
315
        input.tap_script_sigs.clear();
11✔
316
        input.proprietary.clear();
11✔
317
        input.unknown.clear();
11✔
318
    }
11✔
319
    for output in psbt.outputs_mut() {
21✔
320
        output.bip32_derivation.clear();
21✔
321
        output.tap_internal_key = None;
21✔
322
        output.tap_key_origins.clear();
21✔
323
        output.proprietary.clear();
21✔
324
        output.unknown.clear();
21✔
325
    }
21✔
326
}
11✔
327

328
/// Ensure that an additional fee output is sufficient to pay for the specified additional fee
329
fn check_fee_output_amount(
9✔
330
    output: &TxOut,
9✔
331
    fee: bitcoin::Amount,
9✔
332
    clamp_fee_contribution: bool,
9✔
333
) -> Result<bitcoin::Amount, InternalBuildSenderError> {
9✔
334
    if output.value < fee {
9✔
335
        if clamp_fee_contribution {
×
336
            Ok(output.value)
×
337
        } else {
338
            Err(InternalBuildSenderError::FeeOutputValueLowerThanFeeContribution)
×
339
        }
340
    } else {
341
        Ok(fee)
9✔
342
    }
343
}
9✔
344

345
/// Find the sender's change output index by eliminating the payee's output as a candidate.
346
fn find_change_index(
7✔
347
    psbt: &Psbt,
7✔
348
    payee: &Script,
7✔
349
    fee: bitcoin::Amount,
7✔
350
    clamp_fee_contribution: bool,
7✔
351
) -> Result<Option<AdditionalFeeContribution>, InternalBuildSenderError> {
7✔
352
    match (psbt.unsigned_tx.output.len(), clamp_fee_contribution) {
7✔
353
        (0, _) => return Err(InternalBuildSenderError::NoOutputs),
×
354
        (1, false) if psbt.unsigned_tx.output[0].script_pubkey == *payee =>
×
355
            return Err(InternalBuildSenderError::FeeOutputValueLowerThanFeeContribution),
×
356
        (1, true) if psbt.unsigned_tx.output[0].script_pubkey == *payee => return Ok(None),
×
357
        (1, _) => return Err(InternalBuildSenderError::MissingPayeeOutput),
×
358
        (2, _) => (),
7✔
359
        _ => return Err(InternalBuildSenderError::AmbiguousChangeOutput),
×
360
    }
361
    let (index, output) = psbt
7✔
362
        .unsigned_tx
7✔
363
        .output
7✔
364
        .iter()
7✔
365
        .enumerate()
7✔
366
        .find(|(_, output)| output.script_pubkey != *payee)
11✔
367
        .ok_or(InternalBuildSenderError::MultiplePayeeOutputs)?;
7✔
368

369
    Ok(Some(AdditionalFeeContribution {
370
        max_amount: check_fee_output_amount(output, fee, clamp_fee_contribution)?,
7✔
371
        vout: index,
7✔
372
    }))
373
}
7✔
374

375
/// Check that the change output index is not out of bounds
376
/// and that the additional fee contribution is not less than specified.
377
fn check_change_index(
2✔
378
    psbt: &Psbt,
2✔
379
    payee: &Script,
2✔
380
    fee: bitcoin::Amount,
2✔
381
    index: usize,
2✔
382
    clamp_fee_contribution: bool,
2✔
383
) -> Result<AdditionalFeeContribution, InternalBuildSenderError> {
2✔
384
    let output = psbt
2✔
385
        .unsigned_tx
2✔
386
        .output
2✔
387
        .get(index)
2✔
388
        .ok_or(InternalBuildSenderError::ChangeIndexOutOfBounds)?;
2✔
389
    if output.script_pubkey == *payee {
2✔
390
        return Err(InternalBuildSenderError::ChangeIndexPointsAtPayee);
×
391
    }
2✔
392
    Ok(AdditionalFeeContribution {
2✔
393
        max_amount: check_fee_output_amount(output, fee, clamp_fee_contribution)?,
2✔
394
        vout: index,
2✔
395
    })
396
}
2✔
397

398
fn determine_fee_contribution(
11✔
399
    psbt: &Psbt,
11✔
400
    payee: &Script,
11✔
401
    fee_contribution: Option<(bitcoin::Amount, Option<usize>)>,
11✔
402
    clamp_fee_contribution: bool,
11✔
403
) -> Result<Option<AdditionalFeeContribution>, InternalBuildSenderError> {
11✔
404
    Ok(match fee_contribution {
9✔
405
        Some((fee, None)) => find_change_index(psbt, payee, fee, clamp_fee_contribution)?,
7✔
406
        Some((fee, Some(index))) =>
2✔
407
            Some(check_change_index(psbt, payee, fee, index, clamp_fee_contribution)?),
2✔
408
        None => None,
2✔
409
    })
410
}
11✔
411

412
fn serialize_url(
13✔
413
    endpoint: Url,
13✔
414
    disable_output_substitution: bool,
13✔
415
    fee_contribution: Option<AdditionalFeeContribution>,
13✔
416
    min_fee_rate: FeeRate,
13✔
417
    version: &str,
13✔
418
) -> Result<Url, url::ParseError> {
13✔
419
    let mut url = endpoint;
13✔
420
    url.query_pairs_mut().append_pair("v", version);
13✔
421
    if disable_output_substitution {
13✔
422
        url.query_pairs_mut().append_pair("disableoutputsubstitution", "true");
1✔
423
    }
12✔
424
    if let Some(AdditionalFeeContribution { max_amount, vout }) = fee_contribution {
13✔
425
        url.query_pairs_mut()
10✔
426
            .append_pair("additionalfeeoutputindex", &vout.to_string())
10✔
427
            .append_pair("maxadditionalfeecontribution", &max_amount.to_sat().to_string());
10✔
428
    }
10✔
429
    if min_fee_rate > FeeRate::ZERO {
13✔
430
        // TODO serialize in rust-bitcoin <https://github.com/rust-bitcoin/rust-bitcoin/pull/1787/files#diff-c2ea40075e93ccd068673873166cfa3312ec7439d6bc5a4cbc03e972c7e045c4>
4✔
431
        let float_fee_rate = min_fee_rate.to_sat_per_kwu() as f32 / 250.0_f32;
4✔
432
        url.query_pairs_mut().append_pair("minfeerate", &float_fee_rate.to_string());
4✔
433
    }
9✔
434
    Ok(url)
13✔
435
}
13✔
436

437
#[cfg(test)]
438
pub(crate) mod test {
439
    use std::str::FromStr;
440

441
    use bitcoin::psbt::Psbt;
442
    use bitcoin::FeeRate;
443
    use url::Url;
444

445
    use super::serialize_url;
446
    use crate::psbt::PsbtExt;
447
    use crate::send::AdditionalFeeContribution;
448

449
    pub(crate) const ORIGINAL_PSBT: &str = "cHNidP8BAHMCAAAAAY8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////AtyVuAUAAAAAF6kUHehJ8GnSdBUOOv6ujXLrWmsJRDCHgIQeAAAAAAAXqRR3QJbbz0hnQ8IvQ0fptGn+votneofTAAAAAAEBIKgb1wUAAAAAF6kU3k4ekGHKWRNbA1rV5tR5kEVDVNCHAQcXFgAUx4pFclNVgo1WWAdN1SYNX8tphTABCGsCRzBEAiB8Q+A6dep+Rz92vhy26lT0AjZn4PRLi8Bf9qoB/CMk0wIgP/Rj2PWZ3gEjUkTlhDRNAQ0gXwTO7t9n+V14pZ6oljUBIQMVmsAaoNWHVMS02LfTSe0e388LNitPa1UQZyOihY+FFgABABYAFEb2Giu6c4KO5YW0pfw3lGp9jMUUAAA=";
450
    const PAYJOIN_PROPOSAL: &str = "cHNidP8BAJwCAAAAAo8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////jye60aAl3JgZdaIERvjkeh72VYZuTGH/ps2I4l0IO4MBAAAAAP7///8CJpW4BQAAAAAXqRQd6EnwadJ0FQ46/q6NcutaawlEMIcACT0AAAAAABepFHdAltvPSGdDwi9DR+m0af6+i2d6h9MAAAAAAQEgqBvXBQAAAAAXqRTeTh6QYcpZE1sDWtXm1HmQRUNU0IcBBBYAFMeKRXJTVYKNVlgHTdUmDV/LaYUwIgYDFZrAGqDVh1TEtNi300ntHt/PCzYrT2tVEGcjooWPhRYYSFzWUDEAAIABAACAAAAAgAEAAAAAAAAAAAEBIICEHgAAAAAAF6kUyPLL+cphRyyI5GTUazV0hF2R2NWHAQcXFgAUX4BmVeWSTJIEwtUb5TlPS/ntohABCGsCRzBEAiBnu3tA3yWlT0WBClsXXS9j69Bt+waCs9JcjWtNjtv7VgIge2VYAaBeLPDB6HGFlpqOENXMldsJezF9Gs5amvDQRDQBIQJl1jz1tBt8hNx2owTm+4Du4isx0pmdKNMNIjjaMHFfrQABABYAFEb2Giu6c4KO5YW0pfw3lGp9jMUUIgICygvBWB5prpfx61y1HDAwo37kYP3YRJBvAjtunBAur3wYSFzWUDEAAIABAACAAAAAgAEAAAABAAAAAAA=";
451

452
    pub(crate) fn create_psbt_context() -> super::PsbtContext {
4✔
453
        let original_psbt = Psbt::from_str(ORIGINAL_PSBT).unwrap();
4✔
454
        eprintln!("original: {:#?}", original_psbt);
4✔
455
        let payee = original_psbt.unsigned_tx.output[1].script_pubkey.clone();
4✔
456
        super::PsbtContext {
4✔
457
            original_psbt,
4✔
458
            disable_output_substitution: false,
4✔
459
            fee_contribution: Some(AdditionalFeeContribution {
4✔
460
                max_amount: bitcoin::Amount::from_sat(182),
4✔
461
                vout: 0,
4✔
462
            }),
4✔
463
            min_fee_rate: FeeRate::ZERO,
4✔
464
            payee,
4✔
465
        }
4✔
466
    }
4✔
467

468
    #[test]
469
    fn official_vectors() {
1✔
470
        let original_psbt = Psbt::from_str(ORIGINAL_PSBT).unwrap();
1✔
471
        eprintln!("original: {:#?}", original_psbt);
1✔
472
        let ctx = create_psbt_context();
1✔
473
        let mut proposal = Psbt::from_str(PAYJOIN_PROPOSAL).unwrap();
1✔
474
        eprintln!("proposal: {:#?}", proposal);
1✔
475
        for output in proposal.outputs_mut() {
2✔
476
            output.bip32_derivation.clear();
2✔
477
        }
2✔
478
        for input in proposal.inputs_mut() {
2✔
479
            input.bip32_derivation.clear();
2✔
480
        }
2✔
481
        proposal.inputs_mut()[0].witness_utxo = None;
1✔
482
        ctx.process_proposal(proposal).unwrap();
1✔
483
    }
1✔
484

485
    #[test]
486
    #[should_panic]
487
    fn test_receiver_steals_sender_change() {
1✔
488
        let original_psbt = Psbt::from_str(ORIGINAL_PSBT).unwrap();
1✔
489
        eprintln!("original: {:#?}", original_psbt);
1✔
490
        let ctx = create_psbt_context();
1✔
491
        let mut proposal = Psbt::from_str(PAYJOIN_PROPOSAL).unwrap();
1✔
492
        eprintln!("proposal: {:#?}", proposal);
1✔
493
        for output in proposal.outputs_mut() {
2✔
494
            output.bip32_derivation.clear();
2✔
495
        }
2✔
496
        for input in proposal.inputs_mut() {
2✔
497
            input.bip32_derivation.clear();
2✔
498
        }
2✔
499
        proposal.inputs_mut()[0].witness_utxo = None;
1✔
500
        // Steal 0.5 BTC from the sender output and add it to the receiver output
1✔
501
        proposal.unsigned_tx.output[0].value -= bitcoin::Amount::from_btc(0.5).unwrap();
1✔
502
        proposal.unsigned_tx.output[1].value += bitcoin::Amount::from_btc(0.5).unwrap();
1✔
503
        ctx.process_proposal(proposal).unwrap();
1✔
504
    }
1✔
505

506
    #[test]
507
    fn test_disable_output_substitution_query_param() {
1✔
508
        let url =
1✔
509
            serialize_url(Url::parse("http://localhost").unwrap(), true, None, FeeRate::ZERO, "2")
1✔
510
                .unwrap();
1✔
511
        assert_eq!(url, Url::parse("http://localhost?v=2&disableoutputsubstitution=true").unwrap());
1✔
512

513
        let url =
1✔
514
            serialize_url(Url::parse("http://localhost").unwrap(), false, None, FeeRate::ZERO, "2")
1✔
515
                .unwrap();
1✔
516
        assert_eq!(url, Url::parse("http://localhost?v=2").unwrap());
1✔
517
    }
1✔
518
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc