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

payjoin / rust-payjoin / 12918406633

22 Jan 2025 10:39PM UTC coverage: 65.801% (-6.3%) from 72.12%
12918406633

Pull #502

github

web-flow
Merge 2152c4b39 into 7d8116e03
Pull Request #502: Introduce `directory` feature module

69 of 129 new or added lines in 11 files covered. (53.49%)

270 existing lines in 14 files now uncovered.

3219 of 4892 relevant lines covered (65.8%)

933.94 hits per line

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

87.68
/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
#![cfg_attr(feature = "v2", doc = "To use version 2, refer to [`v2`] module documentation.")]
8
//!
9
//! If you specifically need to use
10
//! version 1, refer to the [`v1`] module documentation.
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
pub mod v1;
28
#[cfg(feature = "v2")]
29
pub mod v2;
30

31
type InternalResult<T> = Result<T, InternalProposalError>;
32

33
/// Data required to validate the response against the original PSBT.
34
#[derive(Debug, Clone)]
35
pub struct PsbtContext {
36
    original_psbt: Psbt,
37
    disable_output_substitution: bool,
38
    fee_contribution: Option<(bitcoin::Amount, usize)>,
39
    min_fee_rate: FeeRate,
40
    payee: ScriptBuf,
41
    allow_mixed_input_scripts: bool,
42
}
43

44
macro_rules! check_eq {
45
    ($proposed:expr, $original:expr, $error:ident) => {
46
        match ($proposed, $original) {
47
            (proposed, original) if proposed != original =>
48
                return Err(InternalProposalError::$error { proposed, original }),
49
            _ => (),
50
        }
51
    };
52
}
53

54
macro_rules! ensure {
55
    ($cond:expr, $error:ident) => {
56
        if !($cond) {
57
            return Err(InternalProposalError::$error);
58
        }
59
    };
60
}
61

62
impl PsbtContext {
63
    fn process_proposal(self, mut proposal: Psbt) -> InternalResult<Psbt> {
5✔
64
        self.basic_checks(&proposal)?;
5✔
65
        self.check_inputs(&proposal)?;
5✔
66
        let contributed_fee = self.check_outputs(&proposal)?;
5✔
67
        self.restore_original_utxos(&mut proposal)?;
3✔
68
        self.check_fees(&proposal, contributed_fee)?;
3✔
69
        Ok(proposal)
3✔
70
    }
5✔
71

72
    fn check_fees(&self, proposal: &Psbt, contributed_fee: Amount) -> InternalResult<()> {
3✔
73
        let proposed_fee = proposal.fee().map_err(InternalProposalError::Psbt)?;
3✔
74
        let original_fee = self.original_psbt.fee().map_err(InternalProposalError::Psbt)?;
3✔
75
        ensure!(original_fee <= proposed_fee, AbsoluteFeeDecreased);
3✔
76
        ensure!(contributed_fee <= proposed_fee - original_fee, PayeeTookContributedFee);
3✔
77
        let original_weight = self.original_psbt.clone().extract_tx_unchecked_fee_rate().weight();
3✔
78
        let original_fee_rate = original_fee / original_weight;
3✔
79
        let original_spks = self
3✔
80
            .original_psbt
3✔
81
            .input_pairs()
3✔
82
            .map(|input_pair| {
3✔
83
                input_pair
3✔
84
                    .previous_txout()
3✔
85
                    .map_err(InternalProposalError::PrevTxOut)
3✔
86
                    .map(|txout| txout.script_pubkey.clone())
3✔
87
            })
3✔
88
            .collect::<InternalResult<Vec<ScriptBuf>>>()?;
3✔
89
        let additional_input_weight = proposal.input_pairs().try_fold(
3✔
90
            Weight::ZERO,
3✔
91
            |acc, input_pair| -> InternalResult<Weight> {
6✔
92
                let spk = &input_pair
6✔
93
                    .previous_txout()
6✔
94
                    .map_err(InternalProposalError::PrevTxOut)?
6✔
95
                    .script_pubkey;
96
                if original_spks.contains(spk) {
6✔
97
                    Ok(acc)
3✔
98
                } else {
99
                    let weight = input_pair
3✔
100
                        .expected_input_weight()
3✔
101
                        .map_err(InternalProposalError::InputWeight)?;
3✔
102
                    Ok(acc + weight)
3✔
103
                }
104
            },
6✔
105
        )?;
3✔
106
        ensure!(
3✔
107
            contributed_fee <= original_fee_rate * additional_input_weight,
3✔
108
            FeeContributionPaysOutputSizeIncrease
3✔
109
        );
3✔
110
        if self.min_fee_rate > FeeRate::ZERO {
3✔
111
            let proposed_weight = proposal.clone().extract_tx_unchecked_fee_rate().weight();
1✔
112
            ensure!(proposed_fee / proposed_weight >= self.min_fee_rate, FeeRateBelowMinimum);
1✔
113
        }
2✔
114
        Ok(())
3✔
115
    }
3✔
116

117
    /// Check that the version and lock time are the same as in the original PSBT.
118
    fn basic_checks(&self, proposal: &Psbt) -> InternalResult<()> {
5✔
119
        check_eq!(
5✔
120
            proposal.unsigned_tx.version,
5✔
121
            self.original_psbt.unsigned_tx.version,
5✔
122
            VersionsDontMatch
5✔
123
        );
5✔
124
        check_eq!(
5✔
125
            proposal.unsigned_tx.lock_time,
5✔
126
            self.original_psbt.unsigned_tx.lock_time,
5✔
127
            LockTimesDontMatch
5✔
128
        );
5✔
129
        Ok(())
5✔
130
    }
5✔
131

132
    fn check_inputs(&self, proposal: &Psbt) -> InternalResult<()> {
5✔
133
        let mut original_inputs = self.original_psbt.input_pairs().peekable();
5✔
134

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

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

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

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

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

268
        ensure!(original_outputs.peek().is_none(), MissingOrShuffledOutputs);
3✔
269
        Ok(contributed_fee)
3✔
270
    }
5✔
271
}
272

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

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

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

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

UNCOV
365
    Ok(Some((check_fee_output_amount(output, fee, clamp_fee_contribution)?, index)))
×
UNCOV
366
}
×
367

368
/// Check that the change output index is not out of bounds
369
/// and that the additional fee contribution is not less than specified.
370
fn check_change_index(
1✔
371
    psbt: &Psbt,
1✔
372
    payee: &Script,
1✔
373
    fee: bitcoin::Amount,
1✔
374
    index: usize,
1✔
375
    clamp_fee_contribution: bool,
1✔
376
) -> Result<(bitcoin::Amount, usize), InternalBuildSenderError> {
1✔
377
    let output = psbt
1✔
378
        .unsigned_tx
1✔
379
        .output
1✔
380
        .get(index)
1✔
381
        .ok_or(InternalBuildSenderError::ChangeIndexOutOfBounds)?;
1✔
382
    if output.script_pubkey == *payee {
1✔
383
        return Err(InternalBuildSenderError::ChangeIndexPointsAtPayee);
×
384
    }
1✔
385
    Ok((check_fee_output_amount(output, fee, clamp_fee_contribution)?, index))
1✔
386
}
1✔
387

388
fn determine_fee_contribution(
1✔
389
    psbt: &Psbt,
1✔
390
    payee: &Script,
1✔
391
    fee_contribution: Option<(bitcoin::Amount, Option<usize>)>,
1✔
392
    clamp_fee_contribution: bool,
1✔
393
) -> Result<Option<(bitcoin::Amount, usize)>, InternalBuildSenderError> {
1✔
394
    Ok(match fee_contribution {
1✔
UNCOV
395
        Some((fee, None)) => find_change_index(psbt, payee, fee, clamp_fee_contribution)?,
×
396
        Some((fee, Some(index))) =>
1✔
397
            Some(check_change_index(psbt, payee, fee, index, clamp_fee_contribution)?),
1✔
UNCOV
398
        None => None,
×
399
    })
400
}
1✔
401

402
fn serialize_url(
6✔
403
    endpoint: Url,
6✔
404
    disable_output_substitution: bool,
6✔
405
    fee_contribution: Option<(bitcoin::Amount, usize)>,
6✔
406
    min_fee_rate: FeeRate,
6✔
407
    version: &str,
6✔
408
) -> Result<Url, url::ParseError> {
6✔
409
    let mut url = endpoint;
6✔
410
    url.query_pairs_mut().append_pair("v", version);
6✔
411
    if disable_output_substitution {
6✔
412
        url.query_pairs_mut().append_pair("disableoutputsubstitution", "true");
2✔
413
    }
4✔
414
    if let Some((amount, index)) = fee_contribution {
6✔
415
        url.query_pairs_mut()
2✔
416
            .append_pair("additionalfeeoutputindex", &index.to_string())
2✔
417
            .append_pair("maxadditionalfeecontribution", &amount.to_sat().to_string());
2✔
418
    }
4✔
419
    if min_fee_rate > FeeRate::ZERO {
6✔
420
        // TODO serialize in rust-bitcoin <https://github.com/rust-bitcoin/rust-bitcoin/pull/1787/files#diff-c2ea40075e93ccd068673873166cfa3312ec7439d6bc5a4cbc03e972c7e045c4>
2✔
421
        let float_fee_rate = min_fee_rate.to_sat_per_kwu() as f32 / 250.0_f32;
2✔
422
        url.query_pairs_mut().append_pair("minfeerate", &float_fee_rate.to_string());
2✔
423
    }
4✔
424
    Ok(url)
6✔
425
}
6✔
426

427
#[cfg(test)]
428
pub(crate) mod test {
429
    use std::str::FromStr;
430

431
    use bitcoin::psbt::Psbt;
432
    use bitcoin::FeeRate;
433
    use url::Url;
434

435
    use super::serialize_url;
436
    use crate::psbt::PsbtExt;
437

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

441
    pub(crate) fn create_psbt_context() -> super::PsbtContext {
8✔
442
        let original_psbt = Psbt::from_str(ORIGINAL_PSBT).unwrap();
8✔
443
        eprintln!("original: {:#?}", original_psbt);
8✔
444
        let payee = original_psbt.unsigned_tx.output[1].script_pubkey.clone();
8✔
445
        super::PsbtContext {
8✔
446
            original_psbt,
8✔
447
            disable_output_substitution: false,
8✔
448
            fee_contribution: Some((bitcoin::Amount::from_sat(182), 0)),
8✔
449
            min_fee_rate: FeeRate::ZERO,
8✔
450
            payee,
8✔
451
            allow_mixed_input_scripts: false,
8✔
452
        }
8✔
453
    }
8✔
454

455
    #[test]
456
    fn official_vectors() {
2✔
457
        let original_psbt = Psbt::from_str(ORIGINAL_PSBT).unwrap();
2✔
458
        eprintln!("original: {:#?}", original_psbt);
2✔
459
        let ctx = create_psbt_context();
2✔
460
        let mut proposal = Psbt::from_str(PAYJOIN_PROPOSAL).unwrap();
2✔
461
        eprintln!("proposal: {:#?}", proposal);
2✔
462
        for output in proposal.outputs_mut() {
4✔
463
            output.bip32_derivation.clear();
4✔
464
        }
4✔
465
        for input in proposal.inputs_mut() {
4✔
466
            input.bip32_derivation.clear();
4✔
467
        }
4✔
468
        proposal.inputs_mut()[0].witness_utxo = None;
2✔
469
        ctx.process_proposal(proposal).unwrap();
2✔
470
    }
2✔
471

472
    #[test]
473
    #[should_panic]
474
    fn test_receiver_steals_sender_change() {
2✔
475
        let original_psbt = Psbt::from_str(ORIGINAL_PSBT).unwrap();
2✔
476
        eprintln!("original: {:#?}", original_psbt);
2✔
477
        let ctx = create_psbt_context();
2✔
478
        let mut proposal = Psbt::from_str(PAYJOIN_PROPOSAL).unwrap();
2✔
479
        eprintln!("proposal: {:#?}", proposal);
2✔
480
        for output in proposal.outputs_mut() {
4✔
481
            output.bip32_derivation.clear();
4✔
482
        }
4✔
483
        for input in proposal.inputs_mut() {
4✔
484
            input.bip32_derivation.clear();
4✔
485
        }
4✔
486
        proposal.inputs_mut()[0].witness_utxo = None;
2✔
487
        // Steal 0.5 BTC from the sender output and add it to the receiver output
2✔
488
        proposal.unsigned_tx.output[0].value -= bitcoin::Amount::from_btc(0.5).unwrap();
2✔
489
        proposal.unsigned_tx.output[1].value += bitcoin::Amount::from_btc(0.5).unwrap();
2✔
490
        ctx.process_proposal(proposal).unwrap();
2✔
491
    }
2✔
492

493
    #[test]
494
    fn test_disable_output_substitution_query_param() {
2✔
495
        let url =
2✔
496
            serialize_url(Url::parse("http://localhost").unwrap(), true, None, FeeRate::ZERO, "2")
2✔
497
                .unwrap();
2✔
498
        assert_eq!(url, Url::parse("http://localhost?v=2&disableoutputsubstitution=true").unwrap());
2✔
499

500
        let url =
2✔
501
            serialize_url(Url::parse("http://localhost").unwrap(), false, None, FeeRate::ZERO, "2")
2✔
502
                .unwrap();
2✔
503
        assert_eq!(url, Url::parse("http://localhost?v=2").unwrap());
2✔
504
    }
2✔
505
}
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