Skip to main content

payjoin/core/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//! OHTTP Privacy Warning
13//! Encapsulated requests whether GET or POST—**must not be retried or reused**.
14//! Retransmitting the same ciphertext (including via automatic retries) breaks the unlinkability and privacy guarantees of OHTTP,
15//! as it allows the relay to correlate requests by comparing ciphertexts.
16//! Note: Even fresh requests may be linkable via metadata (e.g. client IP, request timing),
17//! but request reuse makes correlation trivial for the relay.
18
19use bitcoin::psbt::{Psbt, PsbtSighashType};
20use bitcoin::sighash::TapSighashType;
21use bitcoin::{Amount, FeeRate, Script, ScriptBuf, TxOut, Weight};
22pub use error::{BuildSenderError, ResponseError, ValidationError, WellKnownError};
23pub(crate) use error::{InternalBuildSenderError, InternalProposalError, InternalValidationError};
24
25pub use crate::core::error_codes::ErrorCode;
26use crate::core::Url;
27use crate::output_substitution::OutputSubstitution;
28use crate::psbt::{AddressTypeError, PsbtExt, NON_WITNESS_INPUT_WEIGHT};
29use crate::Version;
30
31// See usize casts
32#[cfg(not(any(target_pointer_width = "32", target_pointer_width = "64")))]
33compile_error!("This crate currently only supports 32 bit and 64 bit architectures");
34
35mod error;
36
37#[cfg(feature = "v1")]
38#[cfg_attr(docsrs, doc(cfg(feature = "v1")))]
39pub mod v1;
40
41#[cfg(feature = "v2")]
42#[cfg_attr(docsrs, doc(cfg(feature = "v2")))]
43pub mod v2;
44
45type InternalResult<T> = Result<T, InternalProposalError>;
46
47/// A builder to construct the properties of a `PsbtContext`.
48#[derive(Clone)]
49pub(crate) struct PsbtContextBuilder {
50    pub(crate) psbt: Psbt,
51    pub(crate) payee: ScriptBuf,
52    pub(crate) amount: Option<bitcoin::Amount>,
53    pub(crate) fee_contribution: Option<(bitcoin::Amount, Option<usize>)>,
54    /// Decreases the fee contribution instead of erroring.
55    ///
56    /// If this option is true and a transaction with change amount lower than fee
57    /// contribution is provided then instead of returning error the fee contribution will
58    /// be just lowered in the request to match the change amount.
59    pub(crate) clamp_fee_contribution: bool,
60    pub(crate) min_fee_rate: FeeRate,
61}
62
63impl PsbtContextBuilder {
64    /// Prepare the context from which to make Sender requests
65    ///
66    /// Call [`PsbtContextBuilder::build_recommended()`] or other `build` methods
67    /// to create a [`PsbtContext`]
68    pub fn new(psbt: Psbt, payee: ScriptBuf, amount: Option<bitcoin::Amount>) -> Self {
69        Self {
70            psbt,
71            payee,
72            amount,
73            // Sender's optional parameters
74            fee_contribution: None,
75            clamp_fee_contribution: false,
76            min_fee_rate: FeeRate::ZERO,
77        }
78    }
79
80    // Calculate the recommended fee contribution for an Original PSBT.
81    //
82    // BIP 78 recommends contributing `originalPSBTFeeRate * vsize(sender_input_type)`.
83    // The minfeerate parameter is set if the contribution is available in change.
84    //
85    // This method fails if no recommendation can be made or if the PSBT is malformed.
86    pub fn build_recommended(
87        self,
88        min_fee_rate: FeeRate,
89        output_substitution: OutputSubstitution,
90    ) -> Result<PsbtContext, BuildSenderError> {
91        // TODO support optional batched payout scripts. This would require a change to
92        // build() which now checks for a single payee.
93        let mut payout_scripts = std::iter::once(self.payee.clone());
94
95        // Check if the PSBT is a sweep transaction with only one output that's a payout script and no change
96        if self.psbt.unsigned_tx.output.len() == 1
97            && payout_scripts.all(|script| script == self.psbt.unsigned_tx.output[0].script_pubkey)
98        {
99            return self.build_non_incentivizing(min_fee_rate, output_substitution);
100        }
101
102        if let Some((additional_fee_index, fee_available)) = self
103            .psbt
104            .unsigned_tx
105            .output
106            .clone()
107            .into_iter()
108            .enumerate()
109            .find(|(_, txo)| payout_scripts.all(|script| script != txo.script_pubkey))
110            .map(|(i, txo)| (i, txo.value))
111        {
112            let mut input_pairs = self.psbt.input_pairs();
113            let first_input_pair = input_pairs.next().ok_or(InternalBuildSenderError::NoInputs)?;
114            let mut input_weight = first_input_pair
115                .expected_input_weight()
116                .map_err(InternalBuildSenderError::InputWeight)?;
117            for input_pair in input_pairs {
118                // use cheapest default if mixed input types
119                if input_pair.address_type()? != first_input_pair.address_type()? {
120                    input_weight =
121                        bitcoin::transaction::InputWeightPrediction::P2TR_KEY_NON_DEFAULT_SIGHASH
122                            .weight()
123                            + NON_WITNESS_INPUT_WEIGHT;
124                    break;
125                }
126            }
127
128            let recommended_additional_fee = min_fee_rate
129                .checked_mul_by_weight(input_weight)
130                .ok_or(InternalBuildSenderError::AddressType(AddressTypeError::FeeRateOverflow))?;
131            if fee_available < recommended_additional_fee {
132                tracing::warn!("Insufficient funds to maintain specified minimum feerate.");
133                return self.build_with_additional_fee(
134                    fee_available,
135                    Some(additional_fee_index),
136                    min_fee_rate,
137                    true,
138                    output_substitution,
139                );
140            }
141            return self.build_with_additional_fee(
142                recommended_additional_fee,
143                Some(additional_fee_index),
144                min_fee_rate,
145                false,
146                output_substitution,
147            );
148        }
149        self.build_non_incentivizing(min_fee_rate, output_substitution)
150    }
151
152    /// Offer the receiver contribution to pay for his input.
153    ///
154    /// These parameters will allow the receiver to take `max_fee_contribution` from given change
155    /// output to pay for additional inputs. The recommended fee is `size_of_one_input * fee_rate`.
156    ///
157    /// `change_index` specifies which output can be used to pay fee. If `None` is provided, then
158    /// the output is auto-detected unless the supplied transaction has more than two outputs.
159    ///
160    /// `clamp_fee_contribution` decreases fee contribution instead of erroring.
161    ///
162    /// If this option is true and a transaction with change amount lower than fee
163    /// contribution is provided then instead of returning error the fee contribution will
164    /// be just lowered in the request to match the change amount.
165    pub fn build_with_additional_fee(
166        mut self,
167        max_fee_contribution: bitcoin::Amount,
168        change_index: Option<usize>,
169        min_fee_rate: FeeRate,
170        clamp_fee_contribution: bool,
171        output_substitution: OutputSubstitution,
172    ) -> Result<PsbtContext, BuildSenderError> {
173        self.fee_contribution = Some((max_fee_contribution, change_index));
174        self.clamp_fee_contribution = clamp_fee_contribution;
175        self.min_fee_rate = min_fee_rate;
176        self.build(output_substitution)
177    }
178
179    /// Perform Payjoin without incentivizing the payee to cooperate.
180    ///
181    /// While it's generally better to offer some contribution some users may wish not to.
182    /// This function disables contribution.
183    pub fn build_non_incentivizing(
184        mut self,
185        min_fee_rate: FeeRate,
186        output_substitution: OutputSubstitution,
187    ) -> Result<PsbtContext, BuildSenderError> {
188        // since this is a builder, these should already be cleared
189        // but we'll reset them to be sure
190        self.fee_contribution = None;
191        self.clamp_fee_contribution = false;
192        self.min_fee_rate = min_fee_rate;
193        self.build(output_substitution)
194    }
195
196    fn build(
197        self,
198        output_substitution: OutputSubstitution,
199    ) -> Result<PsbtContext, BuildSenderError> {
200        let psbt =
201            self.psbt.validate().map_err(InternalBuildSenderError::InconsistentOriginalPsbt)?;
202        psbt.validate_input_utxos().map_err(InternalBuildSenderError::InvalidOriginalInput)?;
203
204        // The Original PSBT reaches the receiver already signed, to be held as
205        // a broadcastable fallback. A signature that does not commit to every
206        // input and output leaves that transaction malleable by whoever holds
207        // it, so refuse to build a context around one.
208        //
209        // TODO: BIP-78 does not mention sighash types at all, neither for the
210        // Original PSBT nor in the sender's payjoin proposal checklist. The
211        // spec needs an update stating that a sender signs with SIGHASH_ALL
212        // and rejects anything else on its own inputs.
213        for input in &psbt.inputs {
214            if let Some(sighash_type) = input.sighash_type {
215                ensure(
216                    commits_to_all_inputs_and_outputs(sighash_type),
217                    InternalBuildSenderError::OriginalTxinNonAllSighashType,
218                )?;
219            }
220        }
221
222        check_single_payee(&psbt, &self.payee, self.amount)?;
223        let fee_contribution = determine_fee_contribution(
224            &psbt,
225            &self.payee,
226            self.fee_contribution,
227            self.clamp_fee_contribution,
228        )?;
229
230        Ok(PsbtContext {
231            original_psbt: psbt,
232            output_substitution,
233            fee_contribution,
234            min_fee_rate: self.min_fee_rate,
235            payee: self.payee,
236        })
237    }
238}
239
240#[derive(Debug, Clone, Copy, PartialEq, Eq)]
241#[cfg_attr(feature = "v2", derive(serde::Serialize, serde::Deserialize))]
242pub(crate) struct AdditionalFeeContribution {
243    max_amount: Amount,
244    vout: usize,
245}
246
247/// Data required to validate the response against the original PSBT.
248#[derive(Debug, Clone)]
249#[cfg_attr(feature = "v2", derive(serde::Serialize, serde::Deserialize, PartialEq, Eq))]
250pub struct PsbtContext {
251    original_psbt: Psbt,
252    output_substitution: OutputSubstitution,
253    fee_contribution: Option<AdditionalFeeContribution>,
254    min_fee_rate: FeeRate,
255    payee: ScriptBuf,
256}
257
258macro_rules! check_eq {
259    ($proposed:expr, $original:expr, $error:ident) => {
260        match ($proposed, $original) {
261            (proposed, original) if proposed != original =>
262                return Err(InternalProposalError::$error { proposed, original }),
263            _ => (),
264        }
265    };
266}
267
268fn ensure<T>(condition: bool, error: T) -> Result<(), T> {
269    if !condition {
270        return Err(error);
271    }
272    Ok(())
273}
274
275/// Whether a PSBT input's declared sighash type commits to every input and
276/// output of the transaction.
277///
278/// Only `SIGHASH_ALL` (ECDSA) and `SIGHASHDEFAULT`/`SIGHASH_ALL` (Taproot)
279/// commit to all inputs and outputs. Any other type (`NONE`, `SINGLE`, or an
280/// `ANYONECANPAY` variant) leaves the signed transaction malleable, so the
281/// sender must never sign one of its own inputs with such a type.
282fn commits_to_all_inputs_and_outputs(sighash_type: PsbtSighashType) -> bool {
283    // Taproot reuses the ECDSA sighash encodings and adds SIGHASHDEFAULT at
284    // 0x00, so parsing as taproot recognizes both signature types.
285    matches!(sighash_type.taproot_hash_ty(), Ok(TapSighashType::Default | TapSighashType::All))
286}
287
288impl PsbtContext {
289    fn process_proposal(self, mut proposal: Psbt) -> InternalResult<Psbt> {
290        self.basic_checks(&proposal)?;
291        self.check_inputs(&proposal, true)?;
292        let contributed_fee = self.check_outputs(&proposal)?;
293        self.restore_original_utxos(&mut proposal)?;
294        self.restore_original_outputs(&mut proposal)?;
295        self.check_fees(&proposal, contributed_fee)?;
296        Ok(proposal)
297    }
298
299    fn check_fees(&self, proposal: &Psbt, contributed_fee: Amount) -> InternalResult<()> {
300        let proposed_fee = proposal.fee().map_err(InternalProposalError::Psbt)?;
301        let original_fee = self.original_psbt.fee().map_err(InternalProposalError::Psbt)?;
302        ensure(original_fee <= proposed_fee, InternalProposalError::AbsoluteFeeDecreased)?;
303        ensure(
304            contributed_fee <= proposed_fee - original_fee,
305            InternalProposalError::PayeeTookContributedFee,
306        )?;
307        let original_weight = self.original_psbt.clone().extract_tx_unchecked_fee_rate().weight();
308        let original_fee_rate = original_fee / original_weight;
309        let original_spks = self
310            .original_psbt
311            .input_pairs()
312            .map(|input_pair| {
313                input_pair
314                    .previous_txout()
315                    .map_err(InternalProposalError::PrevTxOut)
316                    .map(|txout| txout.script_pubkey.clone())
317            })
318            .collect::<InternalResult<Vec<ScriptBuf>>>()?;
319        let additional_input_weight = proposal.input_pairs().try_fold(
320            Weight::ZERO,
321            |acc, input_pair| -> InternalResult<Weight> {
322                let spk = &input_pair
323                    .previous_txout()
324                    .map_err(InternalProposalError::PrevTxOut)?
325                    .script_pubkey;
326                if original_spks.contains(spk) {
327                    Ok(acc)
328                } else {
329                    let weight = input_pair
330                        .expected_input_weight()
331                        .map_err(InternalProposalError::InputWeight)?;
332                    Ok(acc + weight)
333                }
334            },
335        )?;
336        ensure(
337            contributed_fee <= original_fee_rate * additional_input_weight,
338            InternalProposalError::FeeContributionPaysOutputSizeIncrease,
339        )?;
340        if self.min_fee_rate > FeeRate::ZERO {
341            let proposed_weight = proposal.clone().extract_tx_unchecked_fee_rate().weight();
342            ensure(
343                proposed_fee / proposed_weight >= self.min_fee_rate,
344                InternalProposalError::FeeRateBelowMinimum,
345            )?;
346        }
347        Ok(())
348    }
349
350    /// Check that the version and lock time are the same as in the original PSBT.
351    fn basic_checks(&self, proposal: &Psbt) -> InternalResult<()> {
352        check_eq!(
353            proposal.unsigned_tx.version,
354            self.original_psbt.unsigned_tx.version,
355            VersionsDontMatch
356        );
357        check_eq!(
358            proposal.unsigned_tx.lock_time,
359            self.original_psbt.unsigned_tx.lock_time,
360            LockTimesDontMatch
361        );
362        Ok(())
363    }
364
365    fn check_inputs(
366        &self,
367        proposal: &Psbt,
368        ensure_receiver_input_finalized: bool,
369    ) -> InternalResult<()> {
370        let mut original_inputs = self.original_psbt.input_pairs().peekable();
371
372        for proposed in proposal.input_pairs() {
373            ensure(
374                proposed.psbtin.bip32_derivation.is_empty(),
375                InternalProposalError::TxInContainsKeyPaths,
376            )?;
377            ensure(
378                proposed.psbtin.partial_sigs.is_empty(),
379                InternalProposalError::ContainsPartialSigs,
380            )?;
381            match original_inputs.peek() {
382                // our (sender)
383                Some(original)
384                    if proposed.txin.previous_output == original.txin.previous_output =>
385                {
386                    check_eq!(
387                        proposed.txin.sequence,
388                        original.txin.sequence,
389                        SenderTxinSequenceChanged
390                    );
391                    ensure(
392                        proposed.psbtin.final_script_sig.is_none(),
393                        InternalProposalError::SenderTxinContainsFinalScriptSig,
394                    )?;
395                    ensure(
396                        proposed.psbtin.final_script_witness.is_none(),
397                        InternalProposalError::SenderTxinContainsFinalScriptWitness,
398                    )?;
399                    // Refuse to sign our own inputs with any sighash type that
400                    // does not commit to every input and output.
401                    if let Some(sighash_type) = proposed.psbtin.sighash_type {
402                        ensure(
403                            commits_to_all_inputs_and_outputs(sighash_type),
404                            InternalProposalError::SenderTxinNonAllSighashType,
405                        )?;
406                    }
407                    original_inputs.next();
408                }
409                // theirs (receiver)
410                None | Some(_) => {
411                    let original = self
412                        .original_psbt
413                        .input_pairs()
414                        .next()
415                        .ok_or(InternalProposalError::NoInputs)?;
416                    if ensure_receiver_input_finalized {
417                        // Verify the PSBT input is finalized
418                        ensure(
419                            proposed.psbtin.final_script_sig.is_some()
420                                || proposed.psbtin.final_script_witness.is_some(),
421                            InternalProposalError::ReceiverTxinNotFinalized,
422                        )?;
423                    }
424                    // Verify that non_witness_utxo or witness_utxo are filled in.
425                    ensure(
426                        proposed.psbtin.witness_utxo.is_some()
427                            || proposed.psbtin.non_witness_utxo.is_some(),
428                        InternalProposalError::ReceiverTxinMissingUtxoInfo,
429                    )?;
430                    ensure(
431                        proposed.txin.sequence == original.txin.sequence,
432                        InternalProposalError::MixedSequence,
433                    )?;
434                }
435            }
436        }
437        ensure(original_inputs.peek().is_none(), InternalProposalError::MissingOrShuffledInputs)?;
438        Ok(())
439    }
440
441    /// Restore Original PSBT utxos that the receiver stripped.
442    /// The BIP78 spec requires utxo information to be removed, but many wallets
443    /// require it to be present to sign.
444    fn restore_original_utxos(&self, proposal: &mut Psbt) -> InternalResult<()> {
445        let mut original_inputs = self.original_psbt.input_pairs().peekable();
446        let proposal_inputs =
447            proposal.unsigned_tx.input.iter().zip(&mut proposal.inputs).peekable();
448
449        for (proposed_txin, proposed_psbtin) in proposal_inputs {
450            if let Some(original) = original_inputs.peek() {
451                if proposed_txin.previous_output == original.txin.previous_output {
452                    proposed_psbtin.non_witness_utxo = original.psbtin.non_witness_utxo.clone();
453                    proposed_psbtin.witness_utxo = original.psbtin.witness_utxo.clone();
454                    proposed_psbtin.bip32_derivation = original.psbtin.bip32_derivation.clone();
455                    proposed_psbtin.tap_internal_key = original.psbtin.tap_internal_key;
456                    proposed_psbtin.tap_key_origins = original.psbtin.tap_key_origins.clone();
457                    proposed_psbtin.witness_script = original.psbtin.witness_script.clone();
458                    original_inputs.next();
459                }
460            }
461        }
462        Ok(())
463    }
464
465    /// Restore Original PSBT outputs that were stripped before sending to the receiver.
466    /// BIP78 spec requires output fields to be removed, but many wallets
467    /// require output fields to be present in order to validate change and payment outputs.
468    fn restore_original_outputs(&self, proposal: &mut Psbt) -> InternalResult<()> {
469        let mut original_outputs = self
470            .original_psbt
471            .unsigned_tx
472            .output
473            .iter()
474            .zip(self.original_psbt.outputs.iter())
475            .peekable();
476        let proposal_outputs = proposal.unsigned_tx.output.iter().zip(proposal.outputs.iter_mut());
477
478        for (proposed_txout, proposed_psbtout) in proposal_outputs {
479            if let Some((original_txout, original_psbtout)) = original_outputs.peek() {
480                if proposed_txout == *original_txout {
481                    *proposed_psbtout = (*original_psbtout).clone();
482                    original_outputs.next();
483                }
484            }
485        }
486
487        Ok(())
488    }
489
490    fn check_outputs(&self, proposal: &Psbt) -> InternalResult<Amount> {
491        let mut original_outputs =
492            self.original_psbt.unsigned_tx.output.iter().enumerate().peekable();
493        let mut contributed_fee = Amount::ZERO;
494
495        for (proposed_txout, proposed_psbtout) in
496            proposal.unsigned_tx.output.iter().zip(&proposal.outputs)
497        {
498            ensure(
499                proposed_psbtout.bip32_derivation.is_empty(),
500                InternalProposalError::TxOutContainsKeyPaths,
501            )?;
502            match (original_outputs.peek(), self.fee_contribution) {
503                // fee output
504                (
505                    Some((original_output_index, original_output)),
506                    Some(AdditionalFeeContribution {
507                        max_amount: max_fee_contrib,
508                        vout: fee_contrib_idx,
509                    }),
510                ) if proposed_txout.script_pubkey == original_output.script_pubkey
511                    && *original_output_index == fee_contrib_idx =>
512                {
513                    if proposed_txout.value < original_output.value {
514                        contributed_fee = original_output.value - proposed_txout.value;
515                        ensure(
516                            contributed_fee <= max_fee_contrib,
517                            InternalProposalError::FeeContributionExceedsMaximum,
518                        )?;
519                        // The remaining fee checks are done in later in `check_fees`
520                    }
521                    original_outputs.next();
522                }
523                // payee output
524                (Some((_original_output_index, original_output)), _)
525                    if original_output.script_pubkey == self.payee =>
526                {
527                    ensure(
528                        self.output_substitution == OutputSubstitution::Enabled
529                            || (proposed_txout.script_pubkey == original_output.script_pubkey
530                                && proposed_txout.value >= original_output.value),
531                        InternalProposalError::DisallowedOutputSubstitution,
532                    )?;
533                    original_outputs.next();
534                }
535                // our output
536                (Some((_original_output_index, original_output)), _)
537                    if proposed_txout.script_pubkey == original_output.script_pubkey =>
538                {
539                    ensure(
540                        proposed_txout.value >= original_output.value,
541                        InternalProposalError::OutputValueDecreased,
542                    )?;
543                    original_outputs.next();
544                }
545                // additional output
546                _ => (),
547            }
548        }
549
550        ensure(original_outputs.peek().is_none(), InternalProposalError::MissingOrShuffledOutputs)?;
551        Ok(contributed_fee)
552    }
553}
554
555/// Ensure that the payee's output scriptPubKey appears in the list of outputs exactly once,
556/// and that the payee's output amount matches the requested amount.
557fn check_single_payee(
558    psbt: &Psbt,
559    script_pubkey: &Script,
560    amount: Option<bitcoin::Amount>,
561) -> Result<(), InternalBuildSenderError> {
562    let mut payee_found = false;
563    for output in &psbt.unsigned_tx.output {
564        if output.script_pubkey == *script_pubkey {
565            if let Some(amount) = amount {
566                if output.value != amount {
567                    return Err(InternalBuildSenderError::PayeeValueNotEqual);
568                }
569            }
570            if payee_found {
571                return Err(InternalBuildSenderError::MultiplePayeeOutputs);
572            }
573            payee_found = true;
574        }
575    }
576    if payee_found {
577        Ok(())
578    } else {
579        Err(InternalBuildSenderError::MissingPayeeOutput)
580    }
581}
582
583fn clear_unneeded_fields(psbt: &mut Psbt) {
584    psbt.xpub_mut().clear();
585    psbt.proprietary_mut().clear();
586    psbt.unknown_mut().clear();
587    for input in psbt.inputs_mut() {
588        input.bip32_derivation.clear();
589        input.tap_internal_key = None;
590        input.tap_key_origins.clear();
591        input.tap_key_sig = None;
592        input.tap_merkle_root = None;
593        input.tap_script_sigs.clear();
594        input.proprietary.clear();
595        input.unknown.clear();
596    }
597    for output in psbt.outputs_mut() {
598        output.bip32_derivation.clear();
599        output.tap_internal_key = None;
600        output.tap_key_origins.clear();
601        output.proprietary.clear();
602        output.unknown.clear();
603    }
604}
605
606/// Ensure that an additional fee output is sufficient to pay for the specified additional fee
607fn check_fee_output_amount(
608    output: &TxOut,
609    fee: bitcoin::Amount,
610    clamp_fee_contribution: bool,
611) -> Result<bitcoin::Amount, InternalBuildSenderError> {
612    if output.value < fee {
613        if clamp_fee_contribution {
614            Ok(output.value)
615        } else {
616            Err(InternalBuildSenderError::FeeOutputValueLowerThanFeeContribution)
617        }
618    } else {
619        Ok(fee)
620    }
621}
622
623/// Find the sender's change output index by eliminating the payee's output as a candidate.
624fn find_change_index(
625    psbt: &Psbt,
626    payee: &Script,
627    fee: bitcoin::Amount,
628    clamp_fee_contribution: bool,
629) -> Result<Option<AdditionalFeeContribution>, InternalBuildSenderError> {
630    match (psbt.unsigned_tx.output.len(), clamp_fee_contribution) {
631        (0, _) => return Err(InternalBuildSenderError::NoOutputs),
632        (1, false) if psbt.unsigned_tx.output[0].script_pubkey == *payee =>
633            return Err(InternalBuildSenderError::FeeOutputValueLowerThanFeeContribution),
634        (1, true) if psbt.unsigned_tx.output[0].script_pubkey == *payee => return Ok(None),
635        (1, _) => return Err(InternalBuildSenderError::MissingPayeeOutput),
636        (2, _) => (),
637        _ => return Err(InternalBuildSenderError::AmbiguousChangeOutput),
638    }
639    let (index, output) = psbt
640        .unsigned_tx
641        .output
642        .iter()
643        .enumerate()
644        .find(|(_, output)| output.script_pubkey != *payee)
645        .ok_or(InternalBuildSenderError::MultiplePayeeOutputs)?;
646
647    Ok(Some(AdditionalFeeContribution {
648        max_amount: check_fee_output_amount(output, fee, clamp_fee_contribution)?,
649        vout: index,
650    }))
651}
652
653/// Check that the change output index is not out of bounds
654/// and that the additional fee contribution is not less than specified.
655fn check_change_index(
656    psbt: &Psbt,
657    payee: &Script,
658    fee: bitcoin::Amount,
659    index: usize,
660    clamp_fee_contribution: bool,
661) -> Result<AdditionalFeeContribution, InternalBuildSenderError> {
662    let output = psbt
663        .unsigned_tx
664        .output
665        .get(index)
666        .ok_or(InternalBuildSenderError::ChangeIndexOutOfBounds)?;
667    if output.script_pubkey == *payee {
668        return Err(InternalBuildSenderError::ChangeIndexPointsAtPayee);
669    }
670    Ok(AdditionalFeeContribution {
671        max_amount: check_fee_output_amount(output, fee, clamp_fee_contribution)?,
672        vout: index,
673    })
674}
675
676fn determine_fee_contribution(
677    psbt: &Psbt,
678    payee: &Script,
679    fee_contribution: Option<(bitcoin::Amount, Option<usize>)>,
680    clamp_fee_contribution: bool,
681) -> Result<Option<AdditionalFeeContribution>, InternalBuildSenderError> {
682    Ok(match fee_contribution {
683        Some((fee, None)) => find_change_index(psbt, payee, fee, clamp_fee_contribution)?,
684        Some((fee, Some(index))) =>
685            Some(check_change_index(psbt, payee, fee, index, clamp_fee_contribution)?),
686        None => None,
687    })
688}
689
690fn serialize_url(
691    endpoint: Url,
692    output_substitution: OutputSubstitution,
693    fee_contribution: Option<AdditionalFeeContribution>,
694    min_fee_rate: FeeRate,
695    version: Version,
696) -> Url {
697    let mut url = endpoint;
698    url.query_pairs_mut().append_pair("v", &version.to_string());
699    if output_substitution == OutputSubstitution::Disabled {
700        url.query_pairs_mut().append_pair("disableoutputsubstitution", "true");
701    }
702    if let Some(AdditionalFeeContribution { max_amount, vout }) = fee_contribution {
703        url.query_pairs_mut()
704            .append_pair("additionalfeeoutputindex", &vout.to_string())
705            .append_pair("maxadditionalfeecontribution", &max_amount.to_sat().to_string());
706    }
707    if min_fee_rate > FeeRate::ZERO {
708        // TODO serialize in rust-bitcoin <https://github.com/rust-bitcoin/rust-bitcoin/pull/1787/files#diff-c2ea40075e93ccd068673873166cfa3312ec7439d6bc5a4cbc03e972c7e045c4>
709        let float_fee_rate = min_fee_rate.to_sat_per_kwu() as f32 / 250.0_f32;
710        url.query_pairs_mut().append_pair("minfeerate", &float_fee_rate.to_string());
711    }
712    url
713}
714
715#[cfg(test)]
716mod test {
717    use bitcoin::absolute::LockTime;
718    use bitcoin::bip32::{DerivationPath, Fingerprint};
719    use bitcoin::ecdsa::Signature;
720    use bitcoin::hex::FromHex;
721    use bitcoin::secp256k1::{Message, PublicKey, Secp256k1, SecretKey, SECP256K1};
722    use bitcoin::taproot::TaprootBuilder;
723    use bitcoin::{Amount, FeeRate, OutPoint, Script, ScriptBuf, Sequence, Witness};
724    use payjoin_test_utils::{
725        BoxError, ADDITIONAL_FEE_OUTPUT_INDEX, MAX_ADDITIONAL_FEE_CONTRIBUTION,
726        PARSED_ORIGINAL_PSBT, PARSED_PAYJOIN_PROPOSAL, PARSED_PAYJOIN_PROPOSAL_WITH_SENDER_INFO,
727    };
728
729    use super::*;
730    use crate::core::Url;
731    use crate::output_substitution::OutputSubstitution;
732    use crate::psbt::PsbtExt;
733    use crate::send::{AdditionalFeeContribution, InternalBuildSenderError, InternalProposalError};
734
735    /// Creates a PSBT context from the original PSBT test vector from BIP-78
736    pub(crate) fn create_psbt_context() -> Result<super::PsbtContext, BoxError> {
737        let payee = PARSED_ORIGINAL_PSBT.unsigned_tx.output[1].script_pubkey.clone();
738        Ok(super::PsbtContext {
739            original_psbt: PARSED_ORIGINAL_PSBT.clone(),
740            output_substitution: OutputSubstitution::Enabled,
741            fee_contribution: Some(AdditionalFeeContribution {
742                max_amount: bitcoin::Amount::from_sat(182),
743                vout: 0,
744            }),
745            min_fee_rate: FeeRate::ZERO,
746            payee,
747        })
748    }
749
750    #[test]
751    fn test_restore_original_utxos() -> Result<(), BoxError> {
752        let mut original_psbt = PARSED_ORIGINAL_PSBT.clone();
753        let mut payjoin_proposal = PARSED_PAYJOIN_PROPOSAL.clone();
754        let payee = original_psbt.unsigned_tx.output[1].script_pubkey.clone();
755        let (_, pk) = SECP256K1.generate_keypair(&mut bitcoin::key::rand::thread_rng());
756        let x_only = pk.x_only_public_key().0;
757        // Fill out dummy data in the original PSBT so we can restore it
758        let _ = original_psbt.inputs[0].tap_internal_key.insert(x_only);
759        let _ = original_psbt.outputs[0].tap_internal_key.insert(x_only);
760        original_psbt.inputs[0]
761            .bip32_derivation
762            .insert(pk, (Fingerprint::default(), DerivationPath::default()));
763        original_psbt.inputs[0]
764            .tap_key_origins
765            .insert(x_only, (vec![], (Fingerprint::default(), DerivationPath::default())));
766        original_psbt.inputs[0].witness_script = Some(payee.clone());
767        let prev_txout = TxOut { value: Amount::ONE_BTC, script_pubkey: payee.clone() };
768        original_psbt.inputs[0].witness_utxo = Some(prev_txout.clone());
769        let psbt_ctx = PsbtContextBuilder::new(original_psbt, payee.clone(), None)
770            .build(OutputSubstitution::Disabled)?;
771        clear_unneeded_fields(&mut payjoin_proposal);
772
773        psbt_ctx.restore_original_utxos(&mut payjoin_proposal)?;
774        assert!(payjoin_proposal.inputs[0].bip32_derivation.contains_key(&pk));
775        assert!(payjoin_proposal.inputs[0].tap_key_origins.contains_key(&x_only));
776        assert_eq!(payjoin_proposal.inputs[0].witness_utxo, Some(prev_txout));
777        assert_eq!(payjoin_proposal.inputs[0].tap_internal_key, Some(x_only));
778        assert_eq!(payjoin_proposal.inputs[0].witness_script, Some(payee));
779        Ok(())
780    }
781
782    #[test]
783    fn test_restore_original_outputs() -> Result<(), BoxError> {
784        let mut original_psbt = PARSED_ORIGINAL_PSBT.clone();
785        let payee = original_psbt.unsigned_tx.output[1].script_pubkey.clone();
786        let (_, pk) = SECP256K1.generate_keypair(&mut bitcoin::key::rand::thread_rng());
787        let x_only = pk.x_only_public_key().0;
788        let taptree = TaprootBuilder::new()
789            .add_leaf(0, payee.clone())?
790            .try_into_taptree()
791            .expect("Valid tap tree");
792
793        original_psbt.outputs[0].witness_script = Some(payee.clone());
794        original_psbt.outputs[0]
795            .bip32_derivation
796            .insert(pk, (Fingerprint::default(), DerivationPath::default()));
797        original_psbt.outputs[0].tap_internal_key = Some(x_only);
798        original_psbt.outputs[0]
799            .tap_key_origins
800            .insert(x_only, (vec![], (Fingerprint::default(), DerivationPath::default())));
801        original_psbt.outputs[0].tap_tree = Some(taptree.clone());
802
803        let psbt_ctx = PsbtContextBuilder::new(original_psbt.clone(), payee.clone(), None)
804            .build(OutputSubstitution::Disabled)?;
805
806        let mut payjoin_proposal = original_psbt.clone();
807        clear_unneeded_fields(&mut payjoin_proposal);
808        psbt_ctx.restore_original_outputs(&mut payjoin_proposal)?;
809        assert_eq!(payjoin_proposal.outputs[0].witness_script, Some(payee));
810        assert!(payjoin_proposal.outputs[0].bip32_derivation.contains_key(&pk));
811        assert!(payjoin_proposal.outputs[0].tap_key_origins.contains_key(&x_only));
812        assert_eq!(payjoin_proposal.outputs[0].tap_internal_key, Some(x_only));
813        assert_eq!(payjoin_proposal.outputs[0].tap_tree, Some(taptree));
814        Ok(())
815    }
816
817    #[test]
818    fn test_determine_fees() -> Result<(), BoxError> {
819        let fee_contribution = determine_fee_contribution(
820            &PARSED_ORIGINAL_PSBT,
821            Script::from_bytes(&<Vec<u8> as FromHex>::from_hex(
822                "0014b60943f60c3ee848828bdace7474a92e81f3fcdd",
823            )?),
824            Some((Amount::from_sat(1000), Some(1))),
825            false,
826        );
827        assert_eq!((*fee_contribution.as_ref().expect("Failed to retrieve fees")).unwrap().vout, 1);
828        assert_eq!(
829            (*fee_contribution.as_ref().expect("Failed to retrieve fees")).unwrap().max_amount,
830            Amount::from_sat(1000)
831        );
832        Ok(())
833    }
834
835    #[test]
836    fn test_insufficient_fees() -> Result<(), BoxError> {
837        let fee_contribution = determine_fee_contribution(
838            &PARSED_ORIGINAL_PSBT,
839            Script::from_bytes(&<Vec<u8> as FromHex>::from_hex(
840                "0014b60943f60c3ee848828bdace7474a92e81f3fcdd",
841            )?),
842            Some((Amount::from_sat(100000000), None)),
843            false,
844        );
845        assert_eq!(
846            fee_contribution.err(),
847            Some(InternalBuildSenderError::FeeOutputValueLowerThanFeeContribution)
848        );
849        // This tests the max allowed fee contribution of the given input amount
850        let fee_contribution = determine_fee_contribution(
851            &PARSED_ORIGINAL_PSBT,
852            Script::from_bytes(&<Vec<u8> as FromHex>::from_hex(
853                "0014b60943f60c3ee848828bdace7474a92e81f3fcdd",
854            )?),
855            Some((Amount::from_sat(95983068), None)),
856            false,
857        );
858        assert!(fee_contribution.is_ok());
859        Ok(())
860    }
861
862    #[test]
863    fn test_self_pay_change_index() -> Result<(), BoxError> {
864        let script_bytes =
865            <Vec<u8> as FromHex>::from_hex("a914774096dbcf486743c22f4347e9b469febe8b677a87")?;
866        let payee_script = Script::from_bytes(&script_bytes);
867        let fee_contribution = determine_fee_contribution(
868            &PARSED_ORIGINAL_PSBT,
869            payee_script,
870            Some((Amount::from_sat(1000), Some(1))),
871            false,
872        );
873        assert_eq!(
874            *payee_script,
875            PARSED_ORIGINAL_PSBT
876                .unsigned_tx
877                .output
878                .get(1)
879                .ok_or(InternalBuildSenderError::ChangeIndexOutOfBounds)
880                .unwrap()
881                .script_pubkey
882        );
883        assert!(fee_contribution.as_ref().is_err(), "determine fee contribution expected Change output points at payee error, but it succeeded");
884        match fee_contribution.as_ref() {
885            Ok(_) => panic!("Expected error, got success"),
886            Err(error) => {
887                assert_eq!(*error, InternalBuildSenderError::ChangeIndexPointsAtPayee);
888            }
889        }
890        Ok(())
891    }
892
893    #[test]
894    fn test_find_change_index() -> Result<(), BoxError> {
895        // All psbt vectors are modifications on the original psbt from bip78
896        // Starts with the unmodified original psbt
897        let mut psbt = PARSED_ORIGINAL_PSBT.clone();
898        let payee_script = ScriptBuf::from_hex("0014b60943f60c3ee848828bdace7474a92e81f3fcdd")?;
899        let fee_contribution = determine_fee_contribution(
900            &psbt,
901            &payee_script,
902            Some((Amount::from_sat(1000), None)),
903            true,
904        );
905        assert!(
906            fee_contribution.as_ref().is_ok(),
907            "Expected an Ok result got: {:#?}",
908            fee_contribution.as_ref().err()
909        );
910        assert_eq!((*fee_contribution.as_ref().expect("Failed to retrieve fees")).unwrap().vout, 0);
911        assert_eq!(
912            (*fee_contribution.as_ref().expect("Failed to retrieve fees")).unwrap().max_amount,
913            Amount::from_sat(1000)
914        );
915
916        // Psbt with zero outputs
917        psbt.outputs.clear();
918        psbt.unsigned_tx.output.clear();
919
920        let fee_contribution = determine_fee_contribution(
921            &psbt,
922            &ScriptBuf::from_hex("0014908eb2d695cf78e39a621d1561655790d1a8c60f")?,
923            Some((Amount::from_sat(1000), None)),
924            true,
925        );
926        assert_eq!(fee_contribution, Err(InternalBuildSenderError::NoOutputs));
927
928        // Psbt with identical receiver outputs
929        let mut psbt = PARSED_ORIGINAL_PSBT.clone();
930        psbt.outputs[1] = psbt.outputs[0].clone();
931        psbt.unsigned_tx.output[1].script_pubkey = psbt.unsigned_tx.output[0].script_pubkey.clone();
932
933        let fee_contribution = determine_fee_contribution(
934            &psbt,
935            &ScriptBuf::from_hex("a9141de849f069d274150e3afeae8d72eb5a6b09443087")?,
936            Some((Amount::from_sat(1000), None)),
937            true,
938        );
939        assert_eq!(fee_contribution, Err(InternalBuildSenderError::MultiplePayeeOutputs));
940
941        // Psbt with only one output
942        let mut psbt = PARSED_ORIGINAL_PSBT.clone();
943        psbt.outputs.pop();
944        psbt.unsigned_tx.output.pop();
945
946        let fee_contribution = determine_fee_contribution(
947            &psbt,
948            Script::from_bytes(
949                &<Vec<u8> as FromHex>::from_hex("a9141de849f069d274150e3afeae8d72eb5a6b09443087")
950                    .unwrap(),
951            ),
952            Some((Amount::from_sat(1000), None)),
953            true,
954        );
955        assert_eq!(fee_contribution, Ok(None));
956
957        let fee_contribution = determine_fee_contribution(
958            &psbt,
959            Script::from_bytes(
960                &<Vec<u8> as FromHex>::from_hex("a9141de849f069d274150e3afeae8d72eb5a6b09443087")
961                    .unwrap(),
962            ),
963            Some((Amount::from_sat(1000), None)),
964            false,
965        );
966        assert_eq!(
967            fee_contribution,
968            Err(InternalBuildSenderError::FeeOutputValueLowerThanFeeContribution)
969        );
970
971        let fee_contribution = determine_fee_contribution(
972            &psbt,
973            &payee_script,
974            Some((Amount::from_sat(1000), None)),
975            false,
976        );
977        assert_eq!(fee_contribution, Err(InternalBuildSenderError::MissingPayeeOutput));
978
979        let fee_contribution = determine_fee_contribution(
980            &psbt,
981            &payee_script,
982            Some((Amount::from_sat(1000), None)),
983            true,
984        );
985        assert_eq!(fee_contribution, Err(InternalBuildSenderError::MissingPayeeOutput));
986
987        // Psbt with three total outputs
988        let mut psbt = PARSED_ORIGINAL_PSBT.clone();
989        psbt.outputs.push(psbt.outputs[1].clone());
990        psbt.unsigned_tx.output.push(psbt.unsigned_tx.output[1].clone());
991
992        let fee_contribution = determine_fee_contribution(
993            &psbt,
994            &payee_script,
995            Some((Amount::from_sat(1000), None)),
996            true,
997        );
998        assert_eq!(fee_contribution, Err(InternalBuildSenderError::AmbiguousChangeOutput));
999        Ok(())
1000    }
1001
1002    #[test]
1003    fn test_single_payee_amount_mismatch() -> Result<(), BoxError> {
1004        let payee_script = ScriptBuf::from_hex("a914774096dbcf486743c22f4347e9b469febe8b677a87")?;
1005        let single_payee =
1006            check_single_payee(&PARSED_ORIGINAL_PSBT, &payee_script, Some(Amount::from_sat(1)));
1007        assert!(
1008            PARSED_ORIGINAL_PSBT
1009                .unsigned_tx
1010                .output
1011                .get(1)
1012                .ok_or(InternalBuildSenderError::ChangeIndexOutOfBounds)
1013                .unwrap()
1014                .script_pubkey
1015                == payee_script
1016        );
1017        assert!(
1018            single_payee.is_err(),
1019            "Check single payee expected payee value not equal error, but it succeeded"
1020        );
1021        match single_payee {
1022            Ok(_) => panic!("Expected error, got success"),
1023            Err(error) => {
1024                assert_eq!(error, InternalBuildSenderError::PayeeValueNotEqual);
1025            }
1026        }
1027        Ok(())
1028    }
1029
1030    #[test]
1031    fn test_equal_amount_fee_contribution() -> Result<(), BoxError> {
1032        let mut ctx = create_psbt_context()?;
1033        let mut proposal: bitcoin::Psbt = PARSED_PAYJOIN_PROPOSAL.clone();
1034
1035        ctx.fee_contribution = None;
1036        proposal.unsigned_tx.output[0].value = ctx.original_psbt.unsigned_tx.output[0].value;
1037
1038        assert!(ctx.process_proposal(proposal).is_ok());
1039
1040        Ok(())
1041    }
1042
1043    #[test]
1044    fn test_payee_output_value_decreased() -> Result<(), BoxError> {
1045        let mut ctx = create_psbt_context()?;
1046        let mut proposal: bitcoin::Psbt = PARSED_PAYJOIN_PROPOSAL.clone();
1047
1048        ctx.fee_contribution = None;
1049        proposal.unsigned_tx.output[0].value =
1050            ctx.original_psbt.unsigned_tx.output[0].value - Amount::from_sat(1);
1051
1052        ctx.original_psbt.unsigned_tx.output[0].script_pubkey =
1053            ctx.original_psbt.unsigned_tx.output[1].script_pubkey.clone();
1054        assert!(ctx.clone().process_proposal(proposal.clone()).is_ok());
1055
1056        ctx.original_psbt.unsigned_tx.output[0].script_pubkey = ctx.payee.clone();
1057        assert!(ctx.process_proposal(proposal).is_ok());
1058
1059        Ok(())
1060    }
1061
1062    #[test]
1063    fn test_official_vectors() -> Result<(), BoxError> {
1064        let ctx = create_psbt_context()?;
1065        let mut proposal = PARSED_PAYJOIN_PROPOSAL_WITH_SENDER_INFO.clone();
1066        for output in proposal.outputs_mut() {
1067            output.bip32_derivation.clear();
1068        }
1069        for input in proposal.inputs_mut() {
1070            input.bip32_derivation.clear();
1071        }
1072        proposal.inputs_mut()[0].witness_utxo = None;
1073        let result = ctx.process_proposal(proposal);
1074        assert!(result.is_ok(), "Expected an Ok result got: {:#?}", result.err());
1075        assert_eq!(
1076            result.unwrap().inputs_mut()[0].witness_utxo,
1077            PARSED_ORIGINAL_PSBT.inputs[0].witness_utxo,
1078        );
1079        Ok(())
1080    }
1081
1082    #[test]
1083    fn test_disable_output_substitution_query_param() -> Result<(), BoxError> {
1084        let url = serialize_url(
1085            Url::parse("http://localhost")?,
1086            OutputSubstitution::Disabled,
1087            None,
1088            FeeRate::ZERO,
1089            Version::Two,
1090        );
1091        assert_eq!(url, Url::parse("http://localhost?v=2&disableoutputsubstitution=true")?);
1092
1093        let url = serialize_url(
1094            Url::parse("http://localhost")?,
1095            OutputSubstitution::Enabled,
1096            None,
1097            FeeRate::ZERO,
1098            Version::Two,
1099        );
1100        assert_eq!(url, Url::parse("http://localhost?v=2")?);
1101        Ok(())
1102    }
1103
1104    #[test]
1105    fn test_min_feerate_query_param() -> Result<(), BoxError> {
1106        let url = serialize_url(
1107            Url::parse("http://localhost")?,
1108            OutputSubstitution::Enabled,
1109            None,
1110            FeeRate::from_sat_per_vb(10).expect("Could not parse feerate"),
1111            Version::Two,
1112        );
1113        assert_eq!(url, Url::parse("http://localhost?v=2&minfeerate=10")?);
1114        Ok(())
1115    }
1116
1117    #[test]
1118    fn test_additional_fee_contribution_query_param() -> Result<(), BoxError> {
1119        let url = serialize_url(
1120            Url::parse("http://localhost")?,
1121            OutputSubstitution::Enabled,
1122            Some(AdditionalFeeContribution { max_amount: Amount::from_sat(1000), vout: 0 }),
1123            FeeRate::ZERO,
1124            Version::Two,
1125        );
1126        assert_eq!(
1127            url,
1128            Url::parse(
1129                "http://localhost?v=2&additionalfeeoutputindex=0&maxadditionalfeecontribution=1000"
1130            )?
1131        );
1132        Ok(())
1133    }
1134
1135    /// Test the sender's payjoin proposal checklist
1136    /// See: https://github.com/bitcoin/bips/blob/master/bip-0078.mediawiki#user-content-Senders_payjoin_proposal_checklist
1137    /// TODO: update the BIP78 spec, see the sighash check in [`PsbtContextBuilder::build`].
1138    mod bip78_checklist {
1139        use super::*;
1140
1141        #[test]
1142        fn test_transaction_versions_dont_match() -> Result<(), BoxError> {
1143            let ctx = create_psbt_context()?;
1144            let mut proposal: bitcoin::Psbt = PARSED_PAYJOIN_PROPOSAL.clone();
1145
1146            let original_version = ctx.original_psbt.unsigned_tx.version;
1147            let proposed_version = bitcoin::transaction::Version::non_standard(88);
1148            proposal.unsigned_tx.version = proposed_version;
1149
1150            assert!(matches!(
1151                ctx.process_proposal(proposal),
1152                Err(InternalProposalError::VersionsDontMatch {
1153                    proposed,
1154                    original
1155                }) if proposed == proposed_version && original == original_version
1156            ));
1157            Ok(())
1158        }
1159
1160        #[test]
1161        fn test_transaction_locktimes_dont_match() -> Result<(), BoxError> {
1162            let ctx = create_psbt_context()?;
1163            let mut proposal: bitcoin::Psbt = PARSED_PAYJOIN_PROPOSAL.clone();
1164
1165            let original_locktime = ctx.original_psbt.unsigned_tx.lock_time;
1166            let proposed_locktime = LockTime::from_consensus(
1167                ctx.original_psbt.unsigned_tx.lock_time.to_consensus_u32() - 1,
1168            );
1169            proposal.unsigned_tx.lock_time = proposed_locktime;
1170
1171            assert!(matches!(
1172                ctx.process_proposal(proposal),
1173                Err(InternalProposalError::LockTimesDontMatch {
1174                    proposed,
1175                    original
1176                }) if proposed == proposed_locktime && original == original_locktime
1177            ));
1178            Ok(())
1179        }
1180
1181        #[test]
1182        fn test_key_path_found_in_proposal() -> Result<(), BoxError> {
1183            let ctx = create_psbt_context()?;
1184            let mut proposal: bitcoin::Psbt = PARSED_PAYJOIN_PROPOSAL.clone();
1185
1186            // Add a keypath to the input
1187            let context = Secp256k1::new();
1188            let secret_key =
1189                SecretKey::from_slice(&[0xcd; 32]).expect("32 bytes, within curve order");
1190            proposal.inputs[0].bip32_derivation.insert(
1191                PublicKey::from_secret_key(&context, &secret_key),
1192                bitcoin::bip32::KeySource::default(),
1193            );
1194
1195            assert_eq!(
1196                ctx.process_proposal(proposal).unwrap_err().to_string(),
1197                InternalProposalError::TxInContainsKeyPaths.to_string()
1198            );
1199            Ok(())
1200        }
1201
1202        #[test]
1203        fn test_partial_sig_found_in_proposal() -> Result<(), BoxError> {
1204            let ctx = create_psbt_context()?;
1205            let mut proposal: bitcoin::Psbt = PARSED_PAYJOIN_PROPOSAL.clone();
1206
1207            // Add a keypath to the input
1208            let context = Secp256k1::new();
1209            let secret_key =
1210                SecretKey::from_slice(&[0xcd; 32]).expect("32 bytes, within curve order");
1211            proposal.inputs[0].partial_sigs.insert(
1212                PublicKey::from_secret_key(&context, &secret_key).into(),
1213                Signature::sighash_all(secret_key.sign_ecdsa(Message::from_digest([0; 32]))),
1214            );
1215
1216            assert_eq!(
1217                ctx.process_proposal(proposal).unwrap_err().to_string(),
1218                InternalProposalError::ContainsPartialSigs.to_string()
1219            );
1220            Ok(())
1221        }
1222
1223        #[test]
1224        fn test_sender_input_sequence_number_changed() -> Result<(), BoxError> {
1225            let ctx = create_psbt_context()?;
1226            let mut proposal: bitcoin::Psbt = PARSED_PAYJOIN_PROPOSAL.clone();
1227
1228            // Change the sequence number of the proposal
1229            let original_sequence = proposal.unsigned_tx.input.first().unwrap().sequence;
1230            let proposed_sequence =
1231                Sequence::from_consensus(original_sequence.to_consensus_u32() - 1);
1232            proposal.unsigned_tx.input.get_mut(0).unwrap().sequence = proposed_sequence;
1233
1234            assert!(matches!(
1235                ctx.process_proposal(proposal),
1236                Err(InternalProposalError::SenderTxinSequenceChanged {
1237                    proposed,
1238                    original
1239                }) if proposed == proposed_sequence && original == original_sequence
1240            ));
1241            Ok(())
1242        }
1243
1244        #[test]
1245        fn test_sender_input_final_script_sig_is_present() -> Result<(), BoxError> {
1246            let ctx = create_psbt_context()?;
1247            let mut proposal: bitcoin::Psbt = PARSED_PAYJOIN_PROPOSAL.clone();
1248            proposal.inputs.get_mut(0).unwrap().final_script_sig = Some(ScriptBuf::new());
1249
1250            assert_eq!(
1251                ctx.process_proposal(proposal).unwrap_err().to_string(),
1252                InternalProposalError::SenderTxinContainsFinalScriptSig.to_string()
1253            );
1254
1255            Ok(())
1256        }
1257
1258        #[test]
1259        fn test_sender_input_final_script_witness_is_present() -> Result<(), BoxError> {
1260            let ctx = create_psbt_context()?;
1261            let mut proposal: bitcoin::Psbt = PARSED_PAYJOIN_PROPOSAL.clone();
1262            proposal.inputs.get_mut(0).unwrap().final_script_witness = Some(Witness::new());
1263
1264            assert_eq!(
1265                ctx.process_proposal(proposal).unwrap_err().to_string(),
1266                InternalProposalError::SenderTxinContainsFinalScriptWitness.to_string()
1267            );
1268
1269            Ok(())
1270        }
1271
1272        #[test]
1273        fn test_sender_input_non_all_sighash_type_is_rejected() -> Result<(), BoxError> {
1274            use bitcoin::psbt::PsbtSighashType;
1275            use bitcoin::sighash::{EcdsaSighashType, TapSighashType};
1276
1277            // Any sighash type that does not commit to all inputs and outputs
1278            // must be rejected on the sender's own inputs, both in the Original
1279            // PSBT the sender hands over and in the proposal it gets back.
1280            let invalid_sighash_types = [
1281                PsbtSighashType::from(EcdsaSighashType::None),
1282                PsbtSighashType::from(EcdsaSighashType::Single),
1283                PsbtSighashType::from(EcdsaSighashType::AllPlusAnyoneCanPay),
1284                PsbtSighashType::from(EcdsaSighashType::NonePlusAnyoneCanPay),
1285                PsbtSighashType::from(EcdsaSighashType::SinglePlusAnyoneCanPay),
1286                PsbtSighashType::from(TapSighashType::None),
1287                PsbtSighashType::from(TapSighashType::Single),
1288                PsbtSighashType::from(TapSighashType::AllPlusAnyoneCanPay),
1289                PsbtSighashType::from(TapSighashType::NonePlusAnyoneCanPay),
1290                PsbtSighashType::from(TapSighashType::SinglePlusAnyoneCanPay),
1291            ];
1292
1293            for sighash_type in invalid_sighash_types {
1294                let mut original_psbt = PARSED_ORIGINAL_PSBT.clone();
1295                let payee = original_psbt.unsigned_tx.output[1].script_pubkey.clone();
1296                original_psbt.inputs[0].sighash_type = Some(sighash_type);
1297
1298                assert_eq!(
1299                    PsbtContextBuilder::new(original_psbt, payee, None)
1300                        .build(OutputSubstitution::Disabled)
1301                        .unwrap_err()
1302                        .to_string(),
1303                    BuildSenderError::from(InternalBuildSenderError::OriginalTxinNonAllSighashType)
1304                        .to_string(),
1305                    "sighash type {sighash_type:?} should be rejected in the Original PSBT",
1306                );
1307
1308                let ctx = create_psbt_context()?;
1309                let mut proposal: bitcoin::Psbt = PARSED_PAYJOIN_PROPOSAL.clone();
1310                proposal.inputs.get_mut(0).unwrap().sighash_type = Some(sighash_type);
1311
1312                assert_eq!(
1313                    ctx.process_proposal(proposal).unwrap_err().to_string(),
1314                    InternalProposalError::SenderTxinNonAllSighashType.to_string(),
1315                    "sighash type {sighash_type:?} should be rejected in the proposal",
1316                );
1317            }
1318            Ok(())
1319        }
1320
1321        #[test]
1322        fn test_sender_input_nonstandard_sighash_type_is_rejected() -> Result<(), BoxError> {
1323            use bitcoin::psbt::PsbtSighashType;
1324
1325            // PSBT_IN_SIGHASH_TYPE is a raw u32, so it can hold values that
1326            // parse as neither an ECDSA nor a taproot sighash type. An
1327            // unparsable type commits to nothing knowable, so it must be
1328            // rejected rather than waved through as "not a known bad type".
1329            let nonstandard = [0x04, 0x80, 0xff, 0x100, u32::MAX];
1330
1331            for raw in nonstandard {
1332                let mut original_psbt = PARSED_ORIGINAL_PSBT.clone();
1333                let payee = original_psbt.unsigned_tx.output[1].script_pubkey.clone();
1334                original_psbt.inputs[0].sighash_type = Some(PsbtSighashType::from_u32(raw));
1335
1336                assert_eq!(
1337                    PsbtContextBuilder::new(original_psbt, payee, None)
1338                        .build(OutputSubstitution::Disabled)
1339                        .unwrap_err()
1340                        .to_string(),
1341                    BuildSenderError::from(InternalBuildSenderError::OriginalTxinNonAllSighashType)
1342                        .to_string(),
1343                    "sighash type {raw:#x} should be rejected in the Original PSBT",
1344                );
1345
1346                let ctx = create_psbt_context()?;
1347                let mut proposal: bitcoin::Psbt = PARSED_PAYJOIN_PROPOSAL.clone();
1348                proposal.inputs.get_mut(0).unwrap().sighash_type =
1349                    Some(PsbtSighashType::from_u32(raw));
1350
1351                assert_eq!(
1352                    ctx.process_proposal(proposal).unwrap_err().to_string(),
1353                    InternalProposalError::SenderTxinNonAllSighashType.to_string(),
1354                    "sighash type {raw:#x} should be rejected in the proposal",
1355                );
1356            }
1357            Ok(())
1358        }
1359
1360        #[test]
1361        fn test_sender_input_all_sighash_type_is_accepted() -> Result<(), BoxError> {
1362            use bitcoin::psbt::PsbtSighashType;
1363            use bitcoin::sighash::{EcdsaSighashType, TapSighashType};
1364
1365            // SIGHASH_ALL and taproot SIGHASHDEFAULT/SIGHASH_ALL commit to all
1366            // inputs and outputs, as does an unset type, so all must be accepted
1367            // in the Original PSBT and in the proposal alike.
1368            let acceptable = [
1369                None,
1370                Some(PsbtSighashType::from(EcdsaSighashType::All)),
1371                Some(PsbtSighashType::from(TapSighashType::Default)),
1372                Some(PsbtSighashType::from(TapSighashType::All)),
1373            ];
1374
1375            for sighash_type in acceptable {
1376                let mut original_psbt = PARSED_ORIGINAL_PSBT.clone();
1377                let payee = original_psbt.unsigned_tx.output[1].script_pubkey.clone();
1378                original_psbt.inputs[0].sighash_type = sighash_type;
1379
1380                PsbtContextBuilder::new(original_psbt, payee, None)
1381                    .build(OutputSubstitution::Disabled)
1382                    .unwrap_or_else(|e| panic!("sighash type {sighash_type:?} should build: {e}"));
1383
1384                let ctx = create_psbt_context()?;
1385                let mut proposal: bitcoin::Psbt = PARSED_PAYJOIN_PROPOSAL.clone();
1386                proposal.inputs.get_mut(0).unwrap().sighash_type = sighash_type;
1387
1388                ctx.process_proposal(proposal).unwrap_or_else(|e| {
1389                    panic!("sighash type {sighash_type:?} should be accepted: {e}")
1390                });
1391            }
1392            Ok(())
1393        }
1394
1395        #[test]
1396        fn test_receiver_input_is_not_finalized() -> Result<(), BoxError> {
1397            let ctx = create_psbt_context()?;
1398            let mut proposal: bitcoin::Psbt = PARSED_PAYJOIN_PROPOSAL.clone();
1399
1400            // If the outpoints are different, they are considered a receiver input and will be checked as such
1401            let proposed_outpoint = proposal.unsigned_tx.input.first().unwrap().previous_output;
1402            proposal.unsigned_tx.input.get_mut(0).unwrap().previous_output =
1403                OutPoint::new(proposed_outpoint.txid, proposed_outpoint.vout + 1);
1404
1405            // Make the receiver's input un-finalized
1406            proposal.inputs.get_mut(0).unwrap().final_script_sig = None;
1407            proposal.inputs.get_mut(0).unwrap().final_script_witness = None;
1408
1409            assert_eq!(
1410                ctx.process_proposal(proposal).unwrap_err().to_string(),
1411                InternalProposalError::ReceiverTxinNotFinalized.to_string()
1412            );
1413
1414            Ok(())
1415        }
1416
1417        #[test]
1418        fn test_receiver_input_missing_witness_info() -> Result<(), BoxError> {
1419            let ctx = create_psbt_context()?;
1420            let mut proposal: bitcoin::Psbt = PARSED_PAYJOIN_PROPOSAL.clone();
1421
1422            // If the outpoints are different, they are considered a receiver input and will be checked as such
1423            let proposed_outpoint = proposal.unsigned_tx.input.first().unwrap().previous_output;
1424            proposal.unsigned_tx.input.get_mut(0).unwrap().previous_output =
1425                OutPoint::new(proposed_outpoint.txid, proposed_outpoint.vout + 1);
1426            proposal.inputs.get_mut(0).unwrap().final_script_sig = Some(ScriptBuf::new());
1427            proposal.inputs.get_mut(0).unwrap().final_script_witness = Some(Witness::new());
1428
1429            // Make the receiver's input un-finalized
1430            proposal.inputs.get_mut(0).unwrap().witness_utxo = None;
1431            proposal.inputs.get_mut(0).unwrap().non_witness_utxo = None;
1432
1433            assert_eq!(
1434                ctx.process_proposal(proposal).unwrap_err().to_string(),
1435                InternalProposalError::ReceiverTxinMissingUtxoInfo.to_string()
1436            );
1437
1438            Ok(())
1439        }
1440
1441        #[test]
1442        fn test_receiver_input_has_mixed_sequence_() -> Result<(), BoxError> {
1443            let mut ctx = create_psbt_context()?;
1444            let mut proposal: bitcoin::Psbt = PARSED_PAYJOIN_PROPOSAL.clone();
1445
1446            // If the outpoints are different, they are considered a receiver input and will be checked as such
1447            let proposed_outpoint = proposal.unsigned_tx.input.first().unwrap().previous_output;
1448            proposal.unsigned_tx.input.get_mut(0).unwrap().previous_output =
1449                OutPoint::new(proposed_outpoint.txid, proposed_outpoint.vout + 1);
1450            proposal.inputs.get_mut(0).unwrap().final_script_sig = Some(ScriptBuf::new());
1451            proposal.inputs.get_mut(0).unwrap().final_script_witness = Some(Witness::new());
1452
1453            // Ensure the sequence is different
1454            let sequence = ctx.original_psbt.unsigned_tx.input.get_mut(0).unwrap().sequence;
1455            proposal.unsigned_tx.input.get_mut(0).unwrap().sequence =
1456                Sequence::from_consensus(sequence.to_consensus_u32() + 1);
1457
1458            assert_eq!(
1459                ctx.process_proposal(proposal).unwrap_err().to_string(),
1460                InternalProposalError::MixedSequence.to_string()
1461            );
1462
1463            Ok(())
1464        }
1465
1466        #[test]
1467        fn test_process_proposal_when_missing_original_inputs() -> Result<(), BoxError> {
1468            let ctx = create_psbt_context()?;
1469            let mut proposal: bitcoin::Psbt = PARSED_PAYJOIN_PROPOSAL.clone();
1470
1471            proposal.unsigned_tx.input.clear();
1472            proposal.inputs.clear();
1473
1474            assert_eq!(
1475                ctx.process_proposal(proposal).unwrap_err().to_string(),
1476                InternalProposalError::MissingOrShuffledInputs.to_string()
1477            );
1478
1479            Ok(())
1480        }
1481
1482        #[test]
1483        fn test_process_proposal_when_output_contains_key_path() -> Result<(), BoxError> {
1484            let ctx = create_psbt_context()?;
1485            let mut proposal: bitcoin::Psbt = PARSED_PAYJOIN_PROPOSAL.clone();
1486
1487            let context = Secp256k1::new();
1488            let secret_key =
1489                SecretKey::from_slice(&[0xcd; 32]).expect("32 bytes, within curve order");
1490            proposal.outputs.get_mut(0).unwrap().bip32_derivation.insert(
1491                PublicKey::from_secret_key(&context, &secret_key),
1492                bitcoin::bip32::KeySource::default(),
1493            );
1494
1495            assert_eq!(
1496                ctx.process_proposal(proposal).unwrap_err().to_string(),
1497                InternalProposalError::TxOutContainsKeyPaths.to_string()
1498            );
1499
1500            Ok(())
1501        }
1502
1503        #[test]
1504        fn test_receiver_steals_sender_change() -> Result<(), BoxError> {
1505            let ctx = create_psbt_context()?;
1506            let mut proposal = PARSED_PAYJOIN_PROPOSAL.clone();
1507            // Steal 0.5 BTC from the sender output and add it to the receiver output
1508            proposal.unsigned_tx.output[0].value -= Amount::from_btc(0.5)?;
1509            proposal.unsigned_tx.output[1].value += Amount::from_btc(0.5)?;
1510
1511            assert_eq!(
1512                ctx.process_proposal(proposal).unwrap_err().to_string(),
1513                InternalProposalError::FeeContributionExceedsMaximum.to_string()
1514            );
1515
1516            Ok(())
1517        }
1518
1519        #[test]
1520        fn test_process_proposal_when_payee_output_has_disallowed_output_substitution(
1521        ) -> Result<(), BoxError> {
1522            let mut ctx = create_psbt_context()?;
1523            let mut proposal = PARSED_PAYJOIN_PROPOSAL.clone();
1524            ctx.output_substitution = OutputSubstitution::Disabled;
1525
1526            // When output substitution is disabled ensure that the output value did not decrease
1527            assert!(ctx.clone().process_proposal(proposal.clone()).is_ok());
1528
1529            // When output substitution is disabled still allow increasing the output value
1530            proposal.unsigned_tx.output[0].value += MAX_ADDITIONAL_FEE_CONTRIBUTION;
1531            assert!(ctx.clone().process_proposal(proposal.clone()).is_ok());
1532
1533            proposal.unsigned_tx.output[0].value -= MAX_ADDITIONAL_FEE_CONTRIBUTION;
1534            ctx.original_psbt.unsigned_tx.output.get_mut(0).unwrap().script_pubkey =
1535                ctx.payee.clone();
1536            std::mem::swap(
1537                &mut ctx.original_psbt.unsigned_tx.output[0].value,
1538                &mut proposal.unsigned_tx.output[0].value,
1539            );
1540            assert_eq!(
1541                ctx.process_proposal(proposal).unwrap_err().to_string(),
1542                InternalProposalError::DisallowedOutputSubstitution.to_string()
1543            );
1544
1545            Ok(())
1546        }
1547
1548        #[test]
1549        fn test_process_proposal_when_payee_output_has_allowed_output_substitution(
1550        ) -> Result<(), BoxError> {
1551            let mut ctx = create_psbt_context()?;
1552            let mut proposal = PARSED_PAYJOIN_PROPOSAL.clone();
1553
1554            // Do not make any checks when output substitution is enabled
1555            ctx.output_substitution = OutputSubstitution::Enabled;
1556            ctx.original_psbt.unsigned_tx.output.get_mut(0).unwrap().script_pubkey =
1557                ctx.payee.clone();
1558            assert!(ctx.clone().process_proposal(proposal.clone()).is_ok());
1559
1560            proposal.unsigned_tx.output[0].value += MAX_ADDITIONAL_FEE_CONTRIBUTION;
1561            assert!(ctx.clone().process_proposal(proposal.clone()).is_ok());
1562
1563            proposal.unsigned_tx.output[0].value -= Amount::from_sat(364);
1564            assert!(ctx.process_proposal(proposal).is_ok());
1565
1566            Ok(())
1567        }
1568
1569        #[test]
1570        fn test_process_proposal_when_output_value_decreased() -> Result<(), BoxError> {
1571            let mut ctx = create_psbt_context()?;
1572            let mut proposal: bitcoin::Psbt = PARSED_PAYJOIN_PROPOSAL.clone();
1573
1574            ctx.fee_contribution = None;
1575            proposal.unsigned_tx.output.get_mut(0).unwrap().value =
1576                ctx.original_psbt.unsigned_tx.output.get_mut(0).unwrap().value
1577                    - Amount::from_sat(1);
1578
1579            assert_eq!(
1580                ctx.process_proposal(proposal).unwrap_err().to_string(),
1581                InternalProposalError::OutputValueDecreased.to_string()
1582            );
1583
1584            Ok(())
1585        }
1586
1587        #[test]
1588        fn test_process_proposal_rejects_different_script_in_our_output() -> Result<(), BoxError> {
1589            let mut ctx = create_psbt_context()?;
1590            ctx.fee_contribution = None;
1591            ctx.output_substitution = OutputSubstitution::Enabled;
1592
1593            let mut proposal = PARSED_PAYJOIN_PROPOSAL.clone();
1594            let original_change =
1595                &ctx.original_psbt.unsigned_tx.output[ADDITIONAL_FEE_OUTPUT_INDEX];
1596            assert_ne!(original_change.script_pubkey, ctx.payee);
1597
1598            let change_pos = proposal
1599                .unsigned_tx
1600                .output
1601                .iter()
1602                .position(|o| o.script_pubkey == original_change.script_pubkey)
1603                .expect("proposal should contain the change output");
1604
1605            // Replace the change output with one that has a different script
1606            // but the same value, so the mutation would cause arm 3 to match
1607            // and the value guard would pass (value >= original.value = true).
1608            let dummy_script =
1609                ScriptBuf::from_hex("0014ffffffffffffffffffffffffffffffffffffffffff")?;
1610            proposal.unsigned_tx.output[change_pos] =
1611                TxOut { value: original_change.value, script_pubkey: dummy_script };
1612            assert!(
1613                ctx.process_proposal(proposal).is_err(),
1614                "arm 3 must reject script mismatch in our output"
1615            );
1616
1617            Ok(())
1618        }
1619
1620        #[test]
1621        fn test_process_proposal_when_output_missing() -> Result<(), BoxError> {
1622            let ctx = create_psbt_context()?;
1623            let mut proposal: bitcoin::Psbt = PARSED_PAYJOIN_PROPOSAL.clone();
1624
1625            proposal.unsigned_tx.output.clear();
1626            proposal.outputs.clear();
1627
1628            assert_eq!(
1629                ctx.process_proposal(proposal).unwrap_err().to_string(),
1630                InternalProposalError::MissingOrShuffledOutputs.to_string()
1631            );
1632
1633            Ok(())
1634        }
1635
1636        #[test]
1637        fn test_absolute_fee_less_than_original_psbt() -> Result<(), BoxError> {
1638            let ctx = create_psbt_context()?;
1639            let mut proposal = PARSED_PAYJOIN_PROPOSAL.clone();
1640            for output in proposal.outputs_mut() {
1641                output.bip32_derivation.clear();
1642            }
1643            for input in proposal.inputs_mut() {
1644                input.bip32_derivation.clear();
1645            }
1646
1647            // Reduce the proposed fee to be less than the original fee
1648            proposal.unsigned_tx.output[0].value += bitcoin::Amount::from_sat(183);
1649
1650            assert_eq!(
1651                ctx.process_proposal(proposal).unwrap_err().to_string(),
1652                InternalProposalError::AbsoluteFeeDecreased.to_string()
1653            );
1654
1655            Ok(())
1656        }
1657
1658        #[test]
1659        fn test_payee_took_contributed_fee() -> Result<(), BoxError> {
1660            let ctx = create_psbt_context()?;
1661            let mut proposal = ctx.original_psbt.clone();
1662
1663            for input in proposal.inputs_mut() {
1664                input.bip32_derivation.clear();
1665                input.partial_sigs.clear();
1666                input.final_script_sig = None;
1667                input.final_script_witness = None;
1668            }
1669
1670            let redistributed_amount = Amount::from_sat(1);
1671
1672            // Redistribute 1 sat between outputs so that the net on-chain fee doesn't increase
1673            let output_0 = proposal.unsigned_tx.output[0].value;
1674            proposal.unsigned_tx.output[0].value = output_0 - redistributed_amount;
1675            let output_1 = proposal.unsigned_tx.output[1].value;
1676            proposal.unsigned_tx.output[1].value = output_1 + redistributed_amount;
1677
1678            assert_eq!(
1679                ctx.process_proposal(proposal).unwrap_err().to_string(),
1680                InternalProposalError::PayeeTookContributedFee.to_string()
1681            );
1682
1683            Ok(())
1684        }
1685
1686        #[test]
1687        fn test_fee_contribution_pays_output_size_increase() -> Result<(), BoxError> {
1688            let ctx = create_psbt_context()?;
1689            let mut proposal = ctx.original_psbt.clone();
1690
1691            for input in proposal.inputs_mut() {
1692                input.bip32_derivation.clear();
1693                input.partial_sigs.clear();
1694                input.final_script_sig = None;
1695                input.final_script_witness = None;
1696            }
1697
1698            let contributed_fee = Amount::from_sat(10);
1699            let original_output = proposal.unsigned_tx.output[0].value;
1700            proposal.unsigned_tx.output[0].value = original_output - contributed_fee;
1701
1702            assert_eq!(
1703                ctx.process_proposal(proposal).unwrap_err().to_string(),
1704                InternalProposalError::FeeContributionPaysOutputSizeIncrease.to_string()
1705            );
1706
1707            Ok(())
1708        }
1709
1710        #[test]
1711        fn test_fee_rate_below_minimum() -> Result<(), BoxError> {
1712            let mut ctx = create_psbt_context()?;
1713            let mut proposal = ctx.original_psbt.clone();
1714
1715            for input in proposal.inputs_mut() {
1716                input.bip32_derivation.clear();
1717                input.partial_sigs.clear();
1718                input.final_script_sig = None;
1719                input.final_script_witness = None;
1720            }
1721
1722            // The fee rate will always be below this min_fee_rate
1723            ctx.min_fee_rate = FeeRate::MAX;
1724
1725            assert_eq!(
1726                ctx.process_proposal(proposal).unwrap_err().to_string(),
1727                InternalProposalError::FeeRateBelowMinimum.to_string()
1728            );
1729
1730            Ok(())
1731        }
1732    }
1733}