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

payjoin / rust-payjoin / 13350991407

16 Feb 2025 02:43AM UTC coverage: 79.468% (+0.2%) from 79.269%
13350991407

Pull #538

github

web-flow
Merge 0e46f80b5 into 2627ef20f
Pull Request #538: Make payjoin-cli v1 / v2 features additive

363 of 422 new or added lines in 6 files covered. (86.02%)

2 existing lines in 1 file now uncovered.

4122 of 5187 relevant lines covered (79.47%)

887.41 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> {
13✔
77
        self.basic_checks(&proposal)?;
13✔
78
        self.check_inputs(&proposal)?;
13✔
79
        let contributed_fee = self.check_outputs(&proposal)?;
13✔
80
        self.restore_original_utxos(&mut proposal)?;
12✔
81
        self.check_fees(&proposal, contributed_fee)?;
12✔
82
        Ok(proposal)
12✔
83
    }
13✔
84

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

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

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

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

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

226
        for (proposed_txout, proposed_psbtout) in
25✔
227
            proposal.unsigned_tx.output.iter().zip(&proposal.outputs)
13✔
228
        {
229
            ensure!(proposed_psbtout.bip32_derivation.is_empty(), TxOutContainsKeyPaths);
25✔
230
            match (original_outputs.peek(), self.fee_contribution) {
25✔
231
                // fee output
232
                (
233
                    Some((original_output_index, original_output)),
23✔
234
                    Some(AdditionalFeeContribution {
235
                        max_amount: max_fee_contrib,
12✔
236
                        vout: fee_contrib_idx,
12✔
237
                    }),
238
                ) if proposed_txout.script_pubkey == original_output.script_pubkey
23✔
239
                    && *original_output_index == fee_contrib_idx =>
15✔
240
                {
12✔
241
                    if proposed_txout.value < original_output.value {
12✔
242
                        contributed_fee = original_output.value - proposed_txout.value;
11✔
243
                        ensure!(contributed_fee <= max_fee_contrib, FeeContributionExceedsMaximum);
11✔
244
                        // The remaining fee checks are done in later in `check_fees`
245
                    }
1✔
246
                    original_outputs.next();
11✔
247
                }
248
                // payee output
249
                (Some((_original_output_index, original_output)), _)
12✔
250
                    if original_output.script_pubkey == self.payee =>
12✔
251
                {
12✔
252
                    ensure!(
12✔
253
                        !self.disable_output_substitution
12✔
254
                            || (proposed_txout.script_pubkey == original_output.script_pubkey
×
255
                                && proposed_txout.value >= original_output.value),
×
256
                        DisallowedOutputSubstitution
257
                    );
258
                    original_outputs.next();
12✔
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);
12✔
273
        Ok(contributed_fee)
12✔
274
    }
13✔
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(
13✔
280
    psbt: &Psbt,
13✔
281
    script_pubkey: &Script,
13✔
282
    amount: Option<bitcoin::Amount>,
13✔
283
) -> Result<(), InternalBuildSenderError> {
13✔
284
    let mut payee_found = false;
13✔
285
    for output in &psbt.unsigned_tx.output {
38✔
286
        if output.script_pubkey == *script_pubkey {
25✔
287
            if let Some(amount) = amount {
13✔
288
                if output.value != amount {
10✔
289
                    return Err(InternalBuildSenderError::PayeeValueNotEqual);
×
290
                }
10✔
291
            }
3✔
292
            if payee_found {
13✔
293
                return Err(InternalBuildSenderError::MultiplePayeeOutputs);
×
294
            }
13✔
295
            payee_found = true;
13✔
296
        }
12✔
297
    }
298
    if payee_found {
13✔
299
        Ok(())
13✔
300
    } else {
301
        Err(InternalBuildSenderError::MissingPayeeOutput)
×
302
    }
303
}
13✔
304

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

328
/// Ensure that an additional fee output is sufficient to pay for the specified additional fee
329
fn check_fee_output_amount(
11✔
330
    output: &TxOut,
11✔
331
    fee: bitcoin::Amount,
11✔
332
    clamp_fee_contribution: bool,
11✔
333
) -> Result<bitcoin::Amount, InternalBuildSenderError> {
11✔
334
    if output.value < fee {
11✔
335
        if clamp_fee_contribution {
×
336
            Ok(output.value)
×
337
        } else {
338
            Err(InternalBuildSenderError::FeeOutputValueLowerThanFeeContribution)
×
339
        }
340
    } else {
341
        Ok(fee)
11✔
342
    }
343
}
11✔
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)
9✔
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(
4✔
378
    psbt: &Psbt,
4✔
379
    payee: &Script,
4✔
380
    fee: bitcoin::Amount,
4✔
381
    index: usize,
4✔
382
    clamp_fee_contribution: bool,
4✔
383
) -> Result<AdditionalFeeContribution, InternalBuildSenderError> {
4✔
384
    let output = psbt
4✔
385
        .unsigned_tx
4✔
386
        .output
4✔
387
        .get(index)
4✔
388
        .ok_or(InternalBuildSenderError::ChangeIndexOutOfBounds)?;
4✔
389
    if output.script_pubkey == *payee {
4✔
390
        return Err(InternalBuildSenderError::ChangeIndexPointsAtPayee);
×
391
    }
4✔
392
    Ok(AdditionalFeeContribution {
4✔
393
        max_amount: check_fee_output_amount(output, fee, clamp_fee_contribution)?,
4✔
394
        vout: index,
4✔
395
    })
396
}
4✔
397

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

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