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

payjoin / rust-payjoin / 13912933462

18 Mar 2025 12:51AM UTC coverage: 80.638% (+0.2%) from 80.411%
13912933462

Pull #546

github

web-flow
Merge 53b434118 into bb47c8469
Pull Request #546: fix: corrected the pjos parameter

123 of 134 new or added lines in 11 files covered. (91.79%)

4 existing lines in 3 files now uncovered.

4927 of 6110 relevant lines covered (80.64%)

756.4 hits per line

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

94.46
/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 implementers.
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
use crate::OutputSubstitution;
22

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

27
mod error;
28

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

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

39
#[cfg(feature = "_multiparty")]
40
pub mod multiparty;
41

42
type InternalResult<T> = Result<T, InternalProposalError>;
43

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

51
/// Data required to validate the response against the original PSBT.
52
#[derive(Debug, Clone)]
53
pub struct PsbtContext {
54
    original_psbt: Psbt,
55
    output_substitution: OutputSubstitution,
56
    fee_contribution: Option<AdditionalFeeContribution>,
57
    min_fee_rate: FeeRate,
58
    payee: ScriptBuf,
59
}
60

61
macro_rules! check_eq {
62
    ($proposed:expr, $original:expr, $error:ident) => {
63
        match ($proposed, $original) {
64
            (proposed, original) if proposed != original =>
65
                return Err(InternalProposalError::$error { proposed, original }),
66
            _ => (),
67
        }
68
    };
69
}
70

71
macro_rules! ensure {
72
    ($cond:expr, $error:ident) => {
73
        if !($cond) {
74
            return Err(InternalProposalError::$error);
75
        }
76
    };
77
}
78

79
impl PsbtContext {
80
    fn process_proposal(self, mut proposal: Psbt) -> InternalResult<Psbt> {
13✔
81
        self.basic_checks(&proposal)?;
13✔
82
        self.check_inputs(&proposal)?;
13✔
83
        let contributed_fee = self.check_outputs(&proposal)?;
13✔
84
        self.restore_original_utxos(&mut proposal)?;
12✔
85
        self.check_fees(&proposal, contributed_fee)?;
12✔
86
        Ok(proposal)
12✔
87
    }
13✔
88

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

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

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

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

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

210
        for (proposed_txin, proposed_psbtin) in proposal_inputs {
158✔
211
            if let Some(original) = original_inputs.peek() {
142✔
212
                if proposed_txin.previous_output == original.txin.previous_output {
124✔
213
                    proposed_psbtin.non_witness_utxo = original.psbtin.non_witness_utxo.clone();
16✔
214
                    proposed_psbtin.witness_utxo = original.psbtin.witness_utxo.clone();
16✔
215
                    proposed_psbtin.bip32_derivation = original.psbtin.bip32_derivation.clone();
16✔
216
                    proposed_psbtin.tap_internal_key = original.psbtin.tap_internal_key;
16✔
217
                    proposed_psbtin.tap_key_origins = original.psbtin.tap_key_origins.clone();
16✔
218
                    original_inputs.next();
16✔
219
                }
108✔
220
            }
18✔
221
        }
222
        Ok(())
16✔
223
    }
16✔
224

225
    fn check_outputs(&self, proposal: &Psbt) -> InternalResult<Amount> {
17✔
226
        let mut original_outputs =
17✔
227
            self.original_psbt.unsigned_tx.output.iter().enumerate().peekable();
17✔
228
        let mut contributed_fee = Amount::ZERO;
17✔
229

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

276
        ensure!(original_outputs.peek().is_none(), MissingOrShuffledOutputs);
16✔
277
        Ok(contributed_fee)
16✔
278
    }
17✔
279
}
280

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

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

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

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

373
    Ok(Some(AdditionalFeeContribution {
374
        max_amount: check_fee_output_amount(output, fee, clamp_fee_contribution)?,
8✔
375
        vout: index,
8✔
376
    }))
377
}
8✔
378

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

402
fn determine_fee_contribution(
21✔
403
    psbt: &Psbt,
21✔
404
    payee: &Script,
21✔
405
    fee_contribution: Option<(bitcoin::Amount, Option<usize>)>,
21✔
406
    clamp_fee_contribution: bool,
21✔
407
) -> Result<Option<AdditionalFeeContribution>, InternalBuildSenderError> {
21✔
408
    Ok(match fee_contribution {
15✔
409
        Some((fee, None)) => find_change_index(psbt, payee, fee, clamp_fee_contribution)?,
8✔
410
        Some((fee, Some(index))) =>
7✔
411
            Some(check_change_index(psbt, payee, fee, index, clamp_fee_contribution)?),
7✔
412
        None => None,
6✔
413
    })
414
}
21✔
415

416
fn serialize_url(
28✔
417
    endpoint: Url,
28✔
418
    output_substitution: OutputSubstitution,
28✔
419
    fee_contribution: Option<AdditionalFeeContribution>,
28✔
420
    min_fee_rate: FeeRate,
28✔
421
    version: &str,
28✔
422
) -> Url {
28✔
423
    let mut url = endpoint;
28✔
424
    url.query_pairs_mut().append_pair("v", version);
28✔
425
    if output_substitution == OutputSubstitution::Disabled {
28✔
426
        url.query_pairs_mut().append_pair("disableoutputsubstitution", "true");
5✔
427
    }
23✔
428
    if let Some(AdditionalFeeContribution { max_amount, vout }) = fee_contribution {
28✔
429
        url.query_pairs_mut()
12✔
430
            .append_pair("additionalfeeoutputindex", &vout.to_string())
12✔
431
            .append_pair("maxadditionalfeecontribution", &max_amount.to_sat().to_string());
12✔
432
    }
16✔
433
    if min_fee_rate > FeeRate::ZERO {
28✔
434
        // TODO serialize in rust-bitcoin <https://github.com/rust-bitcoin/rust-bitcoin/pull/1787/files#diff-c2ea40075e93ccd068673873166cfa3312ec7439d6bc5a4cbc03e972c7e045c4>
14✔
435
        let float_fee_rate = min_fee_rate.to_sat_per_kwu() as f32 / 250.0_f32;
14✔
436
        url.query_pairs_mut().append_pair("minfeerate", &float_fee_rate.to_string());
14✔
437
    }
14✔
438
    url
28✔
439
}
28✔
440

441
#[cfg(test)]
442
mod test {
443
    use std::str::FromStr;
444

445
    use bitcoin::hex::FromHex;
446
    use bitcoin::{Amount, FeeRate, Script, XOnlyPublicKey};
447
    use payjoin_test_utils::{BoxError, PARSED_ORIGINAL_PSBT, PARSED_PAYJOIN_PROPOSAL};
448
    use url::Url;
449

450
    use super::{
451
        check_single_payee, clear_unneeded_fields, determine_fee_contribution, serialize_url,
452
    };
453
    use crate::psbt::PsbtExt;
454
    use crate::send::{AdditionalFeeContribution, InternalBuildSenderError, InternalProposalError};
455
    use crate::OutputSubstitution;
456

457
    pub(crate) fn create_psbt_context() -> Result<super::PsbtContext, BoxError> {
4✔
458
        let payee = PARSED_ORIGINAL_PSBT.unsigned_tx.output[1].script_pubkey.clone();
4✔
459
        Ok(super::PsbtContext {
4✔
460
            original_psbt: PARSED_ORIGINAL_PSBT.clone(),
4✔
461
            output_substitution: OutputSubstitution::Enabled,
4✔
462
            fee_contribution: Some(AdditionalFeeContribution {
4✔
463
                max_amount: bitcoin::Amount::from_sat(182),
4✔
464
                vout: 0,
4✔
465
            }),
4✔
466
            min_fee_rate: FeeRate::ZERO,
4✔
467
            payee,
4✔
468
        })
4✔
469
    }
4✔
470

471
    #[test]
472
    fn test_determine_fees() -> Result<(), BoxError> {
1✔
473
        let fee_contribution = determine_fee_contribution(
1✔
474
            &PARSED_ORIGINAL_PSBT,
1✔
475
            Script::from_bytes(&<Vec<u8> as FromHex>::from_hex(
1✔
476
                "0014b60943f60c3ee848828bdace7474a92e81f3fcdd",
1✔
477
            )?),
1✔
478
            Some((Amount::from_sat(1000), Some(1))),
1✔
479
            false,
1✔
480
        );
1✔
481
        assert_eq!((*fee_contribution.as_ref().expect("Failed to retrieve fees")).unwrap().vout, 1);
1✔
482
        assert_eq!(
1✔
483
            (*fee_contribution.as_ref().expect("Failed to retrieve fees")).unwrap().max_amount,
1✔
484
            Amount::from_sat(1000)
1✔
485
        );
1✔
486
        Ok(())
1✔
487
    }
1✔
488

489
    #[test]
490
    fn test_self_pay_change_index() -> Result<(), BoxError> {
1✔
491
        let script_bytes =
1✔
492
            <Vec<u8> as FromHex>::from_hex("a914774096dbcf486743c22f4347e9b469febe8b677a87")?;
1✔
493
        let payee_script = Script::from_bytes(&script_bytes);
1✔
494
        let fee_contribution = determine_fee_contribution(
1✔
495
            &PARSED_ORIGINAL_PSBT,
1✔
496
            payee_script,
1✔
497
            Some((Amount::from_sat(1000), Some(1))),
1✔
498
            false,
1✔
499
        );
1✔
500
        assert_eq!(
1✔
501
            *payee_script,
1✔
502
            PARSED_ORIGINAL_PSBT
1✔
503
                .unsigned_tx
1✔
504
                .output
1✔
505
                .get(1)
1✔
506
                .ok_or(InternalBuildSenderError::ChangeIndexOutOfBounds)
1✔
507
                .unwrap()
1✔
508
                .script_pubkey
1✔
509
        );
1✔
510
        assert!(fee_contribution.as_ref().is_err(), "determine fee contribution expected Change output points at payee error, but it succeeded");
1✔
511
        match fee_contribution.as_ref() {
1✔
512
            Ok(_) => panic!("Expected error, got success"),
×
513
            Err(error) => {
1✔
514
                assert_eq!(*error, InternalBuildSenderError::ChangeIndexPointsAtPayee);
1✔
515
            }
516
        }
517
        Ok(())
1✔
518
    }
1✔
519

520
    #[test]
521
    fn test_find_change_index() -> Result<(), BoxError> {
1✔
522
        let script_bytes =
1✔
523
            <Vec<u8> as FromHex>::from_hex("0014b60943f60c3ee848828bdace7474a92e81f3fcdd")?;
1✔
524
        let payee_script = Script::from_bytes(&script_bytes);
1✔
525
        let fee_contribution = determine_fee_contribution(
1✔
526
            &PARSED_ORIGINAL_PSBT,
1✔
527
            payee_script,
1✔
528
            Some((Amount::from_sat(1000), None)),
1✔
529
            true,
1✔
530
        );
1✔
531
        assert!(
1✔
532
            fee_contribution.as_ref().is_ok(),
1✔
533
            "Expected an Ok result got: {:#?}",
×
534
            fee_contribution.as_ref().err()
×
535
        );
536
        assert_eq!((*fee_contribution.as_ref().expect("Failed to retrieve fees")).unwrap().vout, 0);
1✔
537
        assert_eq!(
1✔
538
            (*fee_contribution.as_ref().expect("Failed to retrieve fees")).unwrap().max_amount,
1✔
539
            Amount::from_sat(1000)
1✔
540
        );
1✔
541
        Ok(())
1✔
542
    }
1✔
543

544
    #[test]
545
    fn test_single_payee_amount_mismatch() -> Result<(), BoxError> {
1✔
546
        let script_bytes =
1✔
547
            <Vec<u8> as FromHex>::from_hex("a914774096dbcf486743c22f4347e9b469febe8b677a87")?;
1✔
548
        let payee_script = Script::from_bytes(&script_bytes);
1✔
549
        let single_payee =
1✔
550
            check_single_payee(&PARSED_ORIGINAL_PSBT, payee_script, Some(Amount::from_sat(1)));
1✔
551
        assert!(
1✔
552
            PARSED_ORIGINAL_PSBT
1✔
553
                .unsigned_tx
1✔
554
                .output
1✔
555
                .get(1)
1✔
556
                .ok_or(InternalBuildSenderError::ChangeIndexOutOfBounds)
1✔
557
                .unwrap()
1✔
558
                .script_pubkey
1✔
559
                == *payee_script
1✔
560
        );
1✔
561
        assert!(
1✔
562
            single_payee.is_err(),
1✔
563
            "Check single payee expected payee value not equal error, but it succeeded"
×
564
        );
565
        match single_payee {
1✔
566
            Ok(_) => panic!("Expected error, got success"),
×
567
            Err(error) => {
1✔
568
                assert_eq!(error, InternalBuildSenderError::PayeeValueNotEqual);
1✔
569
            }
570
        }
571
        Ok(())
1✔
572
    }
1✔
573

574
    #[test]
575
    fn test_clear_unneeded_fields() -> Result<(), BoxError> {
1✔
576
        let mut proposal = PARSED_PAYJOIN_PROPOSAL.clone();
1✔
577
        let x_only_key = XOnlyPublicKey::from_str(
1✔
578
            "4f65949efe60e5be80cf171c06144641e832815de4f6ab3fe0257351aeb22a84",
1✔
579
        )?;
1✔
580
        let _ = proposal.inputs[0].tap_internal_key.insert(x_only_key);
1✔
581
        let _ = proposal.outputs[0].tap_internal_key.insert(x_only_key);
1✔
582
        assert!(proposal.inputs[0].tap_internal_key.is_some());
1✔
583
        assert!(!proposal.inputs[0].bip32_derivation.is_empty());
1✔
584
        assert!(proposal.outputs[0].tap_internal_key.is_some());
1✔
585
        assert!(!proposal.outputs[0].bip32_derivation.is_empty());
1✔
586
        clear_unneeded_fields(&mut proposal);
1✔
587
        assert!(proposal.inputs[0].tap_internal_key.is_none());
1✔
588
        assert!(proposal.inputs[0].bip32_derivation.is_empty());
1✔
589
        assert!(proposal.outputs[0].tap_internal_key.is_none());
1✔
590
        assert!(proposal.outputs[0].bip32_derivation.is_empty());
1✔
591
        Ok(())
1✔
592
    }
1✔
593

594
    #[test]
595
    fn test_official_vectors() -> Result<(), BoxError> {
1✔
596
        let ctx = create_psbt_context()?;
1✔
597
        let mut proposal = PARSED_PAYJOIN_PROPOSAL.clone();
1✔
598
        for output in proposal.outputs_mut() {
2✔
599
            output.bip32_derivation.clear();
2✔
600
        }
2✔
601
        for input in proposal.inputs_mut() {
2✔
602
            input.bip32_derivation.clear();
2✔
603
        }
2✔
604
        proposal.inputs_mut()[0].witness_utxo = None;
1✔
605
        let result = ctx.process_proposal(proposal.clone());
1✔
606
        assert!(result.is_ok(), "Expected an Ok result got: {:#?}", result.err());
1✔
607
        assert_eq!(
1✔
608
            result.unwrap().inputs_mut()[0].witness_utxo,
1✔
609
            PARSED_ORIGINAL_PSBT.inputs[0].witness_utxo,
1✔
610
        );
1✔
611
        Ok(())
1✔
612
    }
1✔
613

614
    #[test]
615
    fn test_receiver_steals_sender_change() -> Result<(), BoxError> {
1✔
616
        let ctx = create_psbt_context()?;
1✔
617
        let mut proposal = PARSED_PAYJOIN_PROPOSAL.clone();
1✔
618
        for output in proposal.outputs_mut() {
2✔
619
            output.bip32_derivation.clear();
2✔
620
        }
2✔
621
        for input in proposal.inputs_mut() {
2✔
622
            input.bip32_derivation.clear();
2✔
623
        }
2✔
624
        proposal.inputs_mut()[0].witness_utxo = None;
1✔
625
        // Steal 0.5 BTC from the sender output and add it to the receiver output
1✔
626
        proposal.unsigned_tx.output[0].value -= bitcoin::Amount::from_btc(0.5)?;
1✔
627
        proposal.unsigned_tx.output[1].value += bitcoin::Amount::from_btc(0.5)?;
1✔
628
        let result = ctx.clone().process_proposal(proposal.clone());
1✔
629
        assert!(
1✔
630
            result.is_err(),
1✔
631
            "Process response expected fee contribution exceeds maximum error, but it succeeded"
×
632
        );
633

634
        match result {
1✔
635
            Ok(_) => panic!("Expected error, got success"),
×
636
            Err(error) => assert_eq!(
1✔
637
                format!("{}", error),
1✔
638
                InternalProposalError::FeeContributionExceedsMaximum.to_string()
1✔
639
            ),
1✔
640
        }
641
        Ok(())
1✔
642
    }
1✔
643

644
    #[test]
645
    fn test_disable_output_substitution_query_param() -> Result<(), BoxError> {
1✔
646
        let url = serialize_url(
1✔
647
            Url::parse("http://localhost")?,
1✔
648
            OutputSubstitution::Disabled,
1✔
649
            None,
1✔
650
            FeeRate::ZERO,
1✔
651
            "2",
1✔
652
        );
1✔
653
        assert_eq!(url, Url::parse("http://localhost?v=2&disableoutputsubstitution=true")?);
1✔
654

655
        let url = serialize_url(
1✔
656
            Url::parse("http://localhost")?,
1✔
657
            OutputSubstitution::Enabled,
1✔
658
            None,
1✔
659
            FeeRate::ZERO,
1✔
660
            "2",
1✔
661
        );
1✔
662
        assert_eq!(url, Url::parse("http://localhost?v=2")?);
1✔
663
        Ok(())
1✔
664
    }
1✔
665
}
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