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

payjoin / rust-payjoin / 13000966288

28 Jan 2025 12:20AM UTC coverage: 78.485% (-0.04%) from 78.528%
13000966288

Pull #514

github

web-flow
Merge 8bdfe6026 into 27f781327
Pull Request #514: add bitcoind to devshell, and setenv BITCOIND_EXE

3648 of 4648 relevant lines covered (78.49%)

988.99 hits per line

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

93.79
/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
}
42

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

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

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

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

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

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

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

184
    /// Restore Original PSBT utxos that the receiver stripped.
185
    /// The BIP78 spec requires utxo information to be removed, but many wallets
186
    /// require it to be present to sign.
187
    fn restore_original_utxos(&self, proposal: &mut Psbt) -> InternalResult<()> {
10✔
188
        let mut original_inputs = self.original_psbt.input_pairs().peekable();
10✔
189
        let proposal_inputs =
10✔
190
            proposal.unsigned_tx.input.iter().zip(&mut proposal.inputs).peekable();
10✔
191

192
        for (proposed_txin, proposed_psbtin) in proposal_inputs {
128✔
193
            if let Some(original) = original_inputs.peek() {
118✔
194
                if proposed_txin.previous_output == original.txin.previous_output {
117✔
195
                    proposed_psbtin.non_witness_utxo = original.psbtin.non_witness_utxo.clone();
10✔
196
                    proposed_psbtin.witness_utxo = original.psbtin.witness_utxo.clone();
10✔
197
                    proposed_psbtin.bip32_derivation = original.psbtin.bip32_derivation.clone();
10✔
198
                    proposed_psbtin.tap_internal_key = original.psbtin.tap_internal_key;
10✔
199
                    proposed_psbtin.tap_key_origins = original.psbtin.tap_key_origins.clone();
10✔
200
                    original_inputs.next();
10✔
201
                }
107✔
202
            }
1✔
203
        }
204
        Ok(())
10✔
205
    }
10✔
206

207
    fn check_outputs(&self, proposal: &Psbt) -> InternalResult<Amount> {
11✔
208
        let mut original_outputs =
11✔
209
            self.original_psbt.unsigned_tx.output.iter().enumerate().peekable();
11✔
210
        let mut contributed_fee = Amount::ZERO;
11✔
211

212
        for (proposed_txout, proposed_psbtout) in
21✔
213
            proposal.unsigned_tx.output.iter().zip(&proposal.outputs)
11✔
214
        {
215
            ensure!(proposed_psbtout.bip32_derivation.is_empty(), TxOutContainsKeyPaths);
21✔
216
            match (original_outputs.peek(), self.fee_contribution) {
21✔
217
                // fee output
218
                (
219
                    Some((original_output_index, original_output)),
19✔
220
                    Some((max_fee_contrib, fee_contrib_idx)),
10✔
221
                ) if proposed_txout.script_pubkey == original_output.script_pubkey
19✔
222
                    && *original_output_index == fee_contrib_idx =>
13✔
223
                {
10✔
224
                    if proposed_txout.value < original_output.value {
10✔
225
                        contributed_fee = original_output.value - proposed_txout.value;
9✔
226
                        ensure!(contributed_fee <= max_fee_contrib, FeeContributionExceedsMaximum);
9✔
227
                        // The remaining fee checks are done in later in `check_fees`
228
                    }
1✔
229
                    original_outputs.next();
9✔
230
                }
231
                // payee output
232
                (Some((_original_output_index, original_output)), _)
10✔
233
                    if original_output.script_pubkey == self.payee =>
10✔
234
                {
10✔
235
                    ensure!(
10✔
236
                        !self.disable_output_substitution
10✔
237
                            || (proposed_txout.script_pubkey == original_output.script_pubkey
×
238
                                && proposed_txout.value >= original_output.value),
×
239
                        DisallowedOutputSubstitution
240
                    );
241
                    original_outputs.next();
10✔
242
                }
243
                // our output
244
                (Some((_original_output_index, original_output)), _)
×
245
                    if proposed_txout.script_pubkey == original_output.script_pubkey =>
×
246
                {
×
247
                    ensure!(proposed_txout.value >= original_output.value, OutputValueDecreased);
×
248
                    original_outputs.next();
×
249
                }
250
                // additional output
251
                _ => (),
1✔
252
            }
253
        }
254

255
        ensure!(original_outputs.peek().is_none(), MissingOrShuffledOutputs);
10✔
256
        Ok(contributed_fee)
10✔
257
    }
11✔
258
}
259

260
/// Ensure that the payee's output scriptPubKey appears in the list of outputs exactly once,
261
/// and that the payee's output amount matches the requested amount.
262
fn check_single_payee(
11✔
263
    psbt: &Psbt,
11✔
264
    script_pubkey: &Script,
11✔
265
    amount: Option<bitcoin::Amount>,
11✔
266
) -> Result<(), InternalBuildSenderError> {
11✔
267
    let mut payee_found = false;
11✔
268
    for output in &psbt.unsigned_tx.output {
32✔
269
        if output.script_pubkey == *script_pubkey {
21✔
270
            if let Some(amount) = amount {
11✔
271
                if output.value != amount {
8✔
272
                    return Err(InternalBuildSenderError::PayeeValueNotEqual);
×
273
                }
8✔
274
            }
3✔
275
            if payee_found {
11✔
276
                return Err(InternalBuildSenderError::MultiplePayeeOutputs);
×
277
            }
11✔
278
            payee_found = true;
11✔
279
        }
10✔
280
    }
281
    if payee_found {
11✔
282
        Ok(())
11✔
283
    } else {
284
        Err(InternalBuildSenderError::MissingPayeeOutput)
×
285
    }
286
}
11✔
287

288
fn clear_unneeded_fields(psbt: &mut Psbt) {
11✔
289
    psbt.xpub_mut().clear();
11✔
290
    psbt.proprietary_mut().clear();
11✔
291
    psbt.unknown_mut().clear();
11✔
292
    for input in psbt.inputs_mut() {
11✔
293
        input.bip32_derivation.clear();
11✔
294
        input.tap_internal_key = None;
11✔
295
        input.tap_key_origins.clear();
11✔
296
        input.tap_key_sig = None;
11✔
297
        input.tap_merkle_root = None;
11✔
298
        input.tap_script_sigs.clear();
11✔
299
        input.proprietary.clear();
11✔
300
        input.unknown.clear();
11✔
301
    }
11✔
302
    for output in psbt.outputs_mut() {
21✔
303
        output.bip32_derivation.clear();
21✔
304
        output.tap_internal_key = None;
21✔
305
        output.tap_key_origins.clear();
21✔
306
        output.proprietary.clear();
21✔
307
        output.unknown.clear();
21✔
308
    }
21✔
309
}
11✔
310

311
/// Ensure that an additional fee output is sufficient to pay for the specified additional fee
312
fn check_fee_output_amount(
9✔
313
    output: &TxOut,
9✔
314
    fee: bitcoin::Amount,
9✔
315
    clamp_fee_contribution: bool,
9✔
316
) -> Result<bitcoin::Amount, InternalBuildSenderError> {
9✔
317
    if output.value < fee {
9✔
318
        if clamp_fee_contribution {
×
319
            Ok(output.value)
×
320
        } else {
321
            Err(InternalBuildSenderError::FeeOutputValueLowerThanFeeContribution)
×
322
        }
323
    } else {
324
        Ok(fee)
9✔
325
    }
326
}
9✔
327

328
/// Find the sender's change output index by eliminating the payee's output as a candidate.
329
fn find_change_index(
7✔
330
    psbt: &Psbt,
7✔
331
    payee: &Script,
7✔
332
    fee: bitcoin::Amount,
7✔
333
    clamp_fee_contribution: bool,
7✔
334
) -> Result<Option<(bitcoin::Amount, usize)>, InternalBuildSenderError> {
7✔
335
    match (psbt.unsigned_tx.output.len(), clamp_fee_contribution) {
7✔
336
        (0, _) => return Err(InternalBuildSenderError::NoOutputs),
×
337
        (1, false) if psbt.unsigned_tx.output[0].script_pubkey == *payee =>
×
338
            return Err(InternalBuildSenderError::FeeOutputValueLowerThanFeeContribution),
×
339
        (1, true) if psbt.unsigned_tx.output[0].script_pubkey == *payee => return Ok(None),
×
340
        (1, _) => return Err(InternalBuildSenderError::MissingPayeeOutput),
×
341
        (2, _) => (),
7✔
342
        _ => return Err(InternalBuildSenderError::AmbiguousChangeOutput),
×
343
    }
344
    let (index, output) = psbt
7✔
345
        .unsigned_tx
7✔
346
        .output
7✔
347
        .iter()
7✔
348
        .enumerate()
7✔
349
        .find(|(_, output)| output.script_pubkey != *payee)
8✔
350
        .ok_or(InternalBuildSenderError::MultiplePayeeOutputs)?;
7✔
351

352
    Ok(Some((check_fee_output_amount(output, fee, clamp_fee_contribution)?, index)))
7✔
353
}
7✔
354

355
/// Check that the change output index is not out of bounds
356
/// and that the additional fee contribution is not less than specified.
357
fn check_change_index(
2✔
358
    psbt: &Psbt,
2✔
359
    payee: &Script,
2✔
360
    fee: bitcoin::Amount,
2✔
361
    index: usize,
2✔
362
    clamp_fee_contribution: bool,
2✔
363
) -> Result<(bitcoin::Amount, usize), InternalBuildSenderError> {
2✔
364
    let output = psbt
2✔
365
        .unsigned_tx
2✔
366
        .output
2✔
367
        .get(index)
2✔
368
        .ok_or(InternalBuildSenderError::ChangeIndexOutOfBounds)?;
2✔
369
    if output.script_pubkey == *payee {
2✔
370
        return Err(InternalBuildSenderError::ChangeIndexPointsAtPayee);
×
371
    }
2✔
372
    Ok((check_fee_output_amount(output, fee, clamp_fee_contribution)?, index))
2✔
373
}
2✔
374

375
fn determine_fee_contribution(
11✔
376
    psbt: &Psbt,
11✔
377
    payee: &Script,
11✔
378
    fee_contribution: Option<(bitcoin::Amount, Option<usize>)>,
11✔
379
    clamp_fee_contribution: bool,
11✔
380
) -> Result<Option<(bitcoin::Amount, usize)>, InternalBuildSenderError> {
11✔
381
    Ok(match fee_contribution {
9✔
382
        Some((fee, None)) => find_change_index(psbt, payee, fee, clamp_fee_contribution)?,
7✔
383
        Some((fee, Some(index))) =>
2✔
384
            Some(check_change_index(psbt, payee, fee, index, clamp_fee_contribution)?),
2✔
385
        None => None,
2✔
386
    })
387
}
11✔
388

389
fn serialize_url(
13✔
390
    endpoint: Url,
13✔
391
    disable_output_substitution: bool,
13✔
392
    fee_contribution: Option<(bitcoin::Amount, usize)>,
13✔
393
    min_fee_rate: FeeRate,
13✔
394
    version: &str,
13✔
395
) -> Result<Url, url::ParseError> {
13✔
396
    let mut url = endpoint;
13✔
397
    url.query_pairs_mut().append_pair("v", version);
13✔
398
    if disable_output_substitution {
13✔
399
        url.query_pairs_mut().append_pair("disableoutputsubstitution", "true");
1✔
400
    }
12✔
401
    if let Some((amount, index)) = fee_contribution {
13✔
402
        url.query_pairs_mut()
10✔
403
            .append_pair("additionalfeeoutputindex", &index.to_string())
10✔
404
            .append_pair("maxadditionalfeecontribution", &amount.to_sat().to_string());
10✔
405
    }
10✔
406
    if min_fee_rate > FeeRate::ZERO {
13✔
407
        // TODO serialize in rust-bitcoin <https://github.com/rust-bitcoin/rust-bitcoin/pull/1787/files#diff-c2ea40075e93ccd068673873166cfa3312ec7439d6bc5a4cbc03e972c7e045c4>
4✔
408
        let float_fee_rate = min_fee_rate.to_sat_per_kwu() as f32 / 250.0_f32;
4✔
409
        url.query_pairs_mut().append_pair("minfeerate", &float_fee_rate.to_string());
4✔
410
    }
9✔
411
    Ok(url)
13✔
412
}
13✔
413

414
#[cfg(test)]
415
pub(crate) mod test {
416
    use std::str::FromStr;
417

418
    use bitcoin::psbt::Psbt;
419
    use bitcoin::FeeRate;
420
    use url::Url;
421

422
    use super::serialize_url;
423
    use crate::psbt::PsbtExt;
424

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

428
    pub(crate) fn create_psbt_context() -> super::PsbtContext {
4✔
429
        let original_psbt = Psbt::from_str(ORIGINAL_PSBT).unwrap();
4✔
430
        eprintln!("original: {:#?}", original_psbt);
4✔
431
        let payee = original_psbt.unsigned_tx.output[1].script_pubkey.clone();
4✔
432
        super::PsbtContext {
4✔
433
            original_psbt,
4✔
434
            disable_output_substitution: false,
4✔
435
            fee_contribution: Some((bitcoin::Amount::from_sat(182), 0)),
4✔
436
            min_fee_rate: FeeRate::ZERO,
4✔
437
            payee,
4✔
438
        }
4✔
439
    }
4✔
440

441
    #[test]
442
    fn official_vectors() {
1✔
443
        let original_psbt = Psbt::from_str(ORIGINAL_PSBT).unwrap();
1✔
444
        eprintln!("original: {:#?}", original_psbt);
1✔
445
        let ctx = create_psbt_context();
1✔
446
        let mut proposal = Psbt::from_str(PAYJOIN_PROPOSAL).unwrap();
1✔
447
        eprintln!("proposal: {:#?}", proposal);
1✔
448
        for output in proposal.outputs_mut() {
2✔
449
            output.bip32_derivation.clear();
2✔
450
        }
2✔
451
        for input in proposal.inputs_mut() {
2✔
452
            input.bip32_derivation.clear();
2✔
453
        }
2✔
454
        proposal.inputs_mut()[0].witness_utxo = None;
1✔
455
        ctx.process_proposal(proposal).unwrap();
1✔
456
    }
1✔
457

458
    #[test]
459
    #[should_panic]
460
    fn test_receiver_steals_sender_change() {
1✔
461
        let original_psbt = Psbt::from_str(ORIGINAL_PSBT).unwrap();
1✔
462
        eprintln!("original: {:#?}", original_psbt);
1✔
463
        let ctx = create_psbt_context();
1✔
464
        let mut proposal = Psbt::from_str(PAYJOIN_PROPOSAL).unwrap();
1✔
465
        eprintln!("proposal: {:#?}", proposal);
1✔
466
        for output in proposal.outputs_mut() {
2✔
467
            output.bip32_derivation.clear();
2✔
468
        }
2✔
469
        for input in proposal.inputs_mut() {
2✔
470
            input.bip32_derivation.clear();
2✔
471
        }
2✔
472
        proposal.inputs_mut()[0].witness_utxo = None;
1✔
473
        // Steal 0.5 BTC from the sender output and add it to the receiver output
1✔
474
        proposal.unsigned_tx.output[0].value -= bitcoin::Amount::from_btc(0.5).unwrap();
1✔
475
        proposal.unsigned_tx.output[1].value += bitcoin::Amount::from_btc(0.5).unwrap();
1✔
476
        ctx.process_proposal(proposal).unwrap();
1✔
477
    }
1✔
478

479
    #[test]
480
    fn test_disable_output_substitution_query_param() {
1✔
481
        let url =
1✔
482
            serialize_url(Url::parse("http://localhost").unwrap(), true, None, FeeRate::ZERO, "2")
1✔
483
                .unwrap();
1✔
484
        assert_eq!(url, Url::parse("http://localhost?v=2&disableoutputsubstitution=true").unwrap());
1✔
485

486
        let url =
1✔
487
            serialize_url(Url::parse("http://localhost").unwrap(), false, None, FeeRate::ZERO, "2")
1✔
488
                .unwrap();
1✔
489
        assert_eq!(url, Url::parse("http://localhost?v=2").unwrap());
1✔
490
    }
1✔
491
}
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