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

payjoin / rust-payjoin / 13890527789

17 Mar 2025 02:52AM UTC coverage: 80.349% (+0.007%) from 80.342%
13890527789

Pull #584

github

web-flow
Merge 871df4149 into 1dce48512
Pull Request #584: [WIP] include std::log output in tracing output

2 of 2 new or added lines in 1 file covered. (100.0%)

2 existing lines in 1 file now uncovered.

4837 of 6020 relevant lines covered (80.35%)

767.65 hits per line

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

94.31
/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

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
#[cfg(feature = "_multiparty")]
39
pub mod multiparty;
40

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

642
    #[test]
643
    fn test_disable_output_substitution_query_param() -> Result<(), BoxError> {
1✔
644
        let url = serialize_url(Url::parse("http://localhost")?, true, None, FeeRate::ZERO, "2");
1✔
645
        assert_eq!(url, Url::parse("http://localhost?v=2&disableoutputsubstitution=true")?);
1✔
646

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

© 2025 Coveralls, Inc