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

payjoin / rust-payjoin / 18230715366

03 Oct 2025 06:34PM UTC coverage: 84.18% (-0.06%) from 84.237%
18230715366

Pull #1141

github

web-flow
Merge 1460d24bb into 5335134f3
Pull Request #1141: Return String instead of Url for endpoint accessors

97 of 99 new or added lines in 8 files covered. (97.98%)

4 existing lines in 3 files now uncovered.

8934 of 10613 relevant lines covered (84.18%)

466.0 hits per line

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

92.63
/payjoin/src/core/send/v1.rs
1
//! Send BIP 78 Payjoin v1
2
//!
3
//! This module contains types and methods used to implement sending via [BIP78
4
//! Payjoin](https://github.com/bitcoin/bips/blob/master/bip-0078.mediawiki).
5
//!
6
//! Usage is pretty simple:
7
//!
8
//! 1. Parse BIP21 as [`payjoin::Uri`](crate::Uri)
9
//! 2. Construct URI request parameters, a finalized “Original PSBT” paying .amount to .address
10
//! 3. (optional) Spawn a thread or async task that will broadcast the original PSBT fallback after
11
//!    delay (e.g. 1 minute) unless canceled
12
//! 4. Construct the [`Sender`] using [`SenderBuilder`] with the PSBT and payjoin uri
13
//! 5. Send the request and receive a response by following on the extracted V1Context
14
//! 6. Sign and finalize the Payjoin Proposal PSBT
15
//! 7. Broadcast the Payjoin Transaction (and cancel the optional fallback broadcast)
16
//!
17
//! This crate is runtime-agnostic. Data persistence, chain interactions, and networking may be
18
//! provided by custom implementations or copy the reference
19
//! [`payjoin-cli`](https://github.com/payjoin/rust-payjoin/tree/master/payjoin-cli) for bitcoind,
20
//! [`nolooking`](https://github.com/chaincase-app/nolooking) for LND, or
21
//! [`bitmask-core`](https://github.com/diba-io/bitmask-core) BDK integration. Bring your own
22
//! wallet and http client.
23

24
use std::str::FromStr;
25

26
use bitcoin::psbt::Psbt;
27
use bitcoin::{Address, Amount, FeeRate};
28
use error::BuildSenderError;
29

30
use super::*;
31
pub use crate::output_substitution::OutputSubstitution;
32
use crate::uri::v1::PjParam;
33
use crate::{PjUri, Request, MAX_CONTENT_LENGTH};
34

35
/// A builder to construct the properties of a `Sender`.
36
#[derive(Clone)]
37
pub struct SenderBuilder {
38
    pub(crate) endpoint: String,
39
    pub(crate) output_substitution: OutputSubstitution,
40
    pub(crate) psbt_ctx_builder: PsbtContextBuilder,
41
}
42

43
impl SenderBuilder {
44
    /// Prepare the context from which to make Sender requests
45
    ///
46
    /// Call [`SenderBuilder::build_recommended()`] or other `build` methods
47
    /// to create a [`Sender`]
48
    pub fn new(psbt: Psbt, uri: PjUri) -> Self {
17✔
49
        Self {
17✔
50
            endpoint: uri.extras.pj_param.endpoint(),
17✔
51
            // Adopt the output substitution preference from the URI
17✔
52
            output_substitution: uri.extras.output_substitution,
17✔
53
            psbt_ctx_builder: PsbtContextBuilder::new(
17✔
54
                psbt,
17✔
55
                uri.address.script_pubkey(),
17✔
56
                uri.amount,
17✔
57
            ),
17✔
58
        }
17✔
59
    }
17✔
60

61
    /// Create a [SenderBuilder] from component parts to mirror [crate::send::v2::SenderBuilder::from_parts]
62
    ///
63
    /// This method allows constructing a v1 [SenderBuilder] using a [PjParam] directly,
64
    /// rather than requiring a full [PjUri].
65
    pub fn from_parts(
1✔
66
        psbt: Psbt,
1✔
67
        pj_param: &PjParam,
1✔
68
        address: &Address,
1✔
69
        amount: Option<Amount>,
1✔
70
    ) -> Self {
1✔
71
        Self {
1✔
72
            endpoint: pj_param.endpoint().to_string(),
1✔
73
            // Default to enabled output substitution for v1 when not specified via URI
1✔
74
            output_substitution: OutputSubstitution::Enabled,
1✔
75
            psbt_ctx_builder: PsbtContextBuilder::new(psbt, address.script_pubkey(), amount),
1✔
76
        }
1✔
77
    }
1✔
78

79
    /// Disable output substitution even if the receiver didn't.
80
    ///
81
    /// This forbids receiver switching output or decreasing amount.
82
    /// It is generally **not** recommended to set this as it may prevent the receiver from
83
    /// doing advanced operations such as opening LN channels and it also guarantees the
84
    /// receiver will **not** reward the sender with a discount.
85
    pub fn always_disable_output_substitution(self) -> Self {
×
86
        Self { output_substitution: OutputSubstitution::Disabled, ..self }
×
87
    }
×
88

89
    // Calculate the recommended fee contribution for an Original PSBT.
90
    //
91
    // BIP 78 recommends contributing `originalPSBTFeeRate * vsize(sender_input_type)`.
92
    // The minfeerate parameter is set if the contribution is available in change.
93
    //
94
    // This method fails if no recommendation can be made or if the PSBT is malformed.
95
    pub fn build_recommended(self, min_fee_rate: FeeRate) -> Result<Sender, BuildSenderError> {
10✔
96
        Ok(Sender {
97
            endpoint: self.endpoint,
10✔
98
            psbt_ctx: self
10✔
99
                .psbt_ctx_builder
10✔
100
                .build_recommended(min_fee_rate, self.output_substitution)?,
10✔
101
        })
102
    }
10✔
103

104
    /// Offer the receiver contribution to pay for his input.
105
    ///
106
    /// These parameters will allow the receiver to take `max_fee_contribution` from given change
107
    /// output to pay for additional inputs. The recommended fee is `size_of_one_input * fee_rate`.
108
    ///
109
    /// `change_index` specifies which output can be used to pay fee. If `None` is provided, then
110
    /// the output is auto-detected unless the supplied transaction has more than two outputs.
111
    ///
112
    /// `clamp_fee_contribution` decreases fee contribution instead of erroring.
113
    ///
114
    /// If this option is true and a transaction with change amount lower than fee
115
    /// contribution is provided then instead of returning error the fee contribution will
116
    /// be just lowered in the request to match the change amount.
117
    pub fn build_with_additional_fee(
8✔
118
        self,
8✔
119
        max_fee_contribution: bitcoin::Amount,
8✔
120
        change_index: Option<usize>,
8✔
121
        min_fee_rate: FeeRate,
8✔
122
        clamp_fee_contribution: bool,
8✔
123
    ) -> Result<Sender, BuildSenderError> {
8✔
124
        Ok(Sender {
125
            endpoint: self.endpoint,
8✔
126
            psbt_ctx: self.psbt_ctx_builder.build_with_additional_fee(
8✔
127
                max_fee_contribution,
8✔
128
                change_index,
8✔
129
                min_fee_rate,
8✔
130
                clamp_fee_contribution,
8✔
131
                self.output_substitution,
8✔
132
            )?,
×
133
        })
134
    }
8✔
135

136
    /// Perform Payjoin without incentivizing the payee to cooperate.
137
    ///
138
    /// While it's generally better to offer some contribution some users may wish not to.
139
    /// This function disables contribution.
140
    pub fn build_non_incentivizing(
×
141
        self,
×
142
        min_fee_rate: FeeRate,
×
143
    ) -> Result<Sender, BuildSenderError> {
×
144
        Ok(Sender {
145
            endpoint: self.endpoint,
×
146
            psbt_ctx: self
×
147
                .psbt_ctx_builder
×
148
                .build_non_incentivizing(min_fee_rate, self.output_substitution)?,
×
149
        })
150
    }
×
151
}
152

153
/// A payjoin V1 sender, allowing the construction of a payjoin V1 request
154
/// and the resulting `V1Context`
155
#[derive(Clone, Debug)]
156
#[cfg_attr(feature = "v2", derive(PartialEq, Eq, serde::Serialize, serde::Deserialize))]
157
pub struct Sender {
158
    /// The endpoint in the Payjoin URI
159
    pub(crate) endpoint: String,
160
    /// The original PSBT.
161
    pub(crate) psbt_ctx: PsbtContext,
162
}
163

164
impl Sender {
165
    /// Construct serialized V1 Request and Context from a Payjoin Proposal
166
    pub fn create_v1_post_request(&self) -> (Request, V1Context) {
13✔
167
        let url = serialize_url(
13✔
168
            &self.endpoint,
13✔
169
            self.psbt_ctx.output_substitution,
13✔
170
            self.psbt_ctx.fee_contribution,
13✔
171
            self.psbt_ctx.min_fee_rate,
13✔
172
            Version::One,
13✔
173
        );
174
        let mut sanitized_psbt = self.psbt_ctx.original_psbt.clone();
13✔
175
        clear_unneeded_fields(&mut sanitized_psbt);
13✔
176
        let body = sanitized_psbt.to_string().as_bytes().to_vec();
13✔
177
        (
13✔
178
            Request::new_v1(&url, &body),
13✔
179
            V1Context {
13✔
180
                psbt_context: PsbtContext {
13✔
181
                    original_psbt: self.psbt_ctx.original_psbt.clone(),
13✔
182
                    output_substitution: self.psbt_ctx.output_substitution,
13✔
183
                    fee_contribution: self.psbt_ctx.fee_contribution,
13✔
184
                    payee: self.psbt_ctx.payee.clone(),
13✔
185
                    min_fee_rate: self.psbt_ctx.min_fee_rate,
13✔
186
                },
13✔
187
            },
13✔
188
        )
13✔
189
    }
13✔
190

191
    /// The endpoint in the Payjoin URI
NEW
192
    pub fn endpoint(&self) -> String { self.endpoint.to_string() }
×
193
}
194

195
/// Data required to validate the response.
196
///
197
/// This type is used to process a BIP78 response.
198
/// Call [`Self::process_response`] on it to continue the BIP78 flow.
199
#[derive(Debug, Clone)]
200
pub struct V1Context {
201
    psbt_context: PsbtContext,
202
}
203

204
impl V1Context {
205
    /// Decodes and validates the response.
206
    ///
207
    /// Call this method with response from receiver to continue BIP78 flow. If the response is
208
    /// valid you will get appropriate PSBT that you should sign and broadcast.
209
    #[inline]
210
    pub fn process_response(self, response: &[u8]) -> Result<Psbt, ResponseError> {
17✔
211
        if response.len() > MAX_CONTENT_LENGTH {
17✔
212
            return Err(ResponseError::from(InternalValidationError::ContentTooLarge));
1✔
213
        }
16✔
214

215
        let res_str = std::str::from_utf8(response).map_err(|_| InternalValidationError::Parse)?;
16✔
216
        let proposal = Psbt::from_str(res_str).map_err(|_| ResponseError::parse(res_str))?;
16✔
217
        self.psbt_context.process_proposal(proposal).map_err(Into::into)
12✔
218
    }
17✔
219
}
220

221
impl ResponseError {
222
    /// Parse a response from the receiver.
223
    ///
224
    /// response must be valid JSON string.
225
    pub(crate) fn parse(response: &str) -> Self {
6✔
226
        match serde_json::from_str(response) {
6✔
227
            Ok(json) => Self::from_json(json),
4✔
228
            Err(_) => InternalValidationError::Parse.into(),
2✔
229
        }
230
    }
6✔
231
}
232

233
#[cfg(test)]
234
mod test {
235
    use std::collections::BTreeMap;
236

237
    use bitcoin::bip32::{self, DerivationPath};
238
    use bitcoin::hex::FromHex;
239
    use bitcoin::key::Secp256k1;
240
    use bitcoin::psbt::raw::ProprietaryKey;
241
    use bitcoin::{psbt, FeeRate, NetworkKind, XOnlyPublicKey};
242
    use payjoin_test_utils::{
243
        BoxError, EXAMPLE_URL, INVALID_PSBT, MULTIPARTY_ORIGINAL_PSBT_ONE, PARSED_ORIGINAL_PSBT,
244
        PARSED_PAYJOIN_PROPOSAL_WITH_SENDER_INFO, PAYJOIN_PROPOSAL,
245
    };
246

247
    use super::*;
248
    use crate::error_codes::ErrorCode;
249
    use crate::send::error::{ResponseError, WellKnownError};
250
    use crate::send::test::create_psbt_context;
251
    use crate::{Uri, UriExt, MAX_CONTENT_LENGTH};
252

253
    const PJ_URI: &str =
254
        "bitcoin:2N47mmrWXsNBvQR6k78hWJoTji57zXwNcU7?amount=0.02&pjos=0&pj=HTTPS://EXAMPLE.COM/";
255

256
    fn pj_uri<'a>() -> PjUri<'a> {
5✔
257
        Uri::try_from(PJ_URI)
5✔
258
            .expect("uri should succeed")
5✔
259
            .assume_checked()
5✔
260
            .check_pj_supported()
5✔
261
            .expect("uri should support payjoin")
5✔
262
    }
5✔
263

264
    fn create_v1_context() -> super::V1Context {
6✔
265
        let psbt_context = create_psbt_context().expect("failed to create context");
6✔
266
        super::V1Context { psbt_context }
6✔
267
    }
6✔
268

269
    #[test]
270
    fn test_clear_unneeded_fields() -> Result<(), BoxError> {
1✔
271
        let mut proposal = PARSED_PAYJOIN_PROPOSAL_WITH_SENDER_INFO.clone();
1✔
272
        let payee = proposal.unsigned_tx.output[1].script_pubkey.clone();
1✔
273
        let x_only_key = XOnlyPublicKey::from_str(
1✔
274
            "4f65949efe60e5be80cf171c06144641e832815de4f6ab3fe0257351aeb22a84",
1✔
275
        )?;
×
276
        let _ = proposal.inputs[0].tap_internal_key.insert(x_only_key);
1✔
277
        let _ = proposal.outputs[0].tap_internal_key.insert(x_only_key);
1✔
278
        assert!(proposal.inputs[0].tap_internal_key.is_some());
1✔
279
        assert!(!proposal.inputs[0].bip32_derivation.is_empty());
1✔
280
        assert!(proposal.outputs[0].tap_internal_key.is_some());
1✔
281
        assert!(!proposal.outputs[0].bip32_derivation.is_empty());
1✔
282
        let mut psbt_ctx = PsbtContextBuilder::new(proposal.clone(), payee, None)
1✔
283
            .build(OutputSubstitution::Disabled)?;
1✔
284

285
        let mut map = BTreeMap::new();
1✔
286
        let secp = Secp256k1::new();
1✔
287
        let seed = Vec::<u8>::from_hex("BEEFCAFE").unwrap();
1✔
288
        let xpriv = bip32::Xpriv::new_master(NetworkKind::Main, &seed).unwrap();
1✔
289
        let xpub: bip32::Xpub = bip32::Xpub::from_priv(&secp, &xpriv);
1✔
290
        let value = (xpriv.fingerprint(&secp), DerivationPath::from_str("42'").unwrap());
1✔
291
        map.insert(xpub, value);
1✔
292
        psbt_ctx.original_psbt.xpub = map;
1✔
293

294
        let mut map = BTreeMap::new();
1✔
295
        let proprietary_key =
1✔
296
            ProprietaryKey { prefix: b"mock_prefix".to_vec(), subtype: 0x00, key: vec![] };
1✔
297
        let value = FromHex::from_hex("BEEFCAFE").unwrap();
1✔
298
        map.insert(proprietary_key, value);
1✔
299
        psbt_ctx.original_psbt.proprietary = map;
1✔
300

301
        let mut map = BTreeMap::new();
1✔
302
        let unknown_key: psbt::raw::Key = psbt::raw::Key { type_value: 0x00, key: vec![] };
1✔
303
        let value = FromHex::from_hex("BEEFCAFE").unwrap();
1✔
304
        map.insert(unknown_key, value);
1✔
305
        psbt_ctx.original_psbt.unknown = map;
1✔
306

307
        let sender = Sender { endpoint: EXAMPLE_URL.to_string(), psbt_ctx };
1✔
308

309
        let body = sender.create_v1_post_request().0.body;
1✔
310
        let res_str = std::str::from_utf8(&body)?;
1✔
311
        let proposal = Psbt::from_str(res_str)?;
1✔
312
        assert!(proposal.inputs[0].tap_internal_key.is_none());
1✔
313
        assert!(proposal.inputs[0].bip32_derivation.is_empty());
1✔
314
        assert!(proposal.outputs[0].tap_internal_key.is_none());
1✔
315
        assert!(proposal.outputs[0].bip32_derivation.is_empty());
1✔
316
        assert!(proposal.xpub.is_empty());
1✔
317
        assert!(proposal.proprietary.is_empty());
1✔
318
        assert!(proposal.unknown.is_empty());
1✔
319
        Ok(())
1✔
320
    }
1✔
321

322
    /// This test adds mutation coverage for build_recommended when the outputs are equal to the
323
    /// payee scripts forcing build_non_incentivising to run.
324
    #[test]
325
    fn test_build_recommended_output_is_payee() -> Result<(), BoxError> {
1✔
326
        let mut psbt = PARSED_ORIGINAL_PSBT.clone();
1✔
327
        psbt.unsigned_tx.output[0] = TxOut {
1✔
328
            value: Amount::from_sat(2000000),
1✔
329
            script_pubkey: ScriptBuf::from_hex("a9141de849f069d274150e3afeae8d72eb5a6b09443087")
1✔
330
                .unwrap(),
1✔
331
        };
1✔
332
        psbt.unsigned_tx.output.push(psbt.unsigned_tx.output[1].clone());
1✔
333
        psbt.outputs.push(psbt.outputs[1].clone());
1✔
334
        let sender = SenderBuilder::new(
1✔
335
            psbt.clone(),
1✔
336
            Uri::try_from("bitcoin:34R9npMiyq6KY81DeMMBTgUoAeueyKeycZ?amount=0.02&pjos=0&pj=HTTPS://EXAMPLE.COM/")
1✔
337
                .map_err(|e| format!("{e}"))?
1✔
338
                .assume_checked()
1✔
339
                .check_pj_supported()
1✔
340
                .map_err(|e| format!("{e}"))?,
1✔
341
        )
342
        .build_recommended(FeeRate::MIN);
1✔
343
        assert!(sender.is_ok(), "{:#?}", sender.err());
1✔
344
        assert_eq!(
1✔
345
            sender.unwrap().psbt_ctx.fee_contribution.unwrap().max_amount,
1✔
346
            Amount::from_sat(0)
1✔
347
        );
348

349
        Ok(())
1✔
350
    }
1✔
351

352
    /// This test is to make sure that the input_pairs for loop inside of build_recommended
353
    /// runs at least once.
354
    /// The first branch adds coverage on the for loop and the second branch ensures that the first
355
    /// and second input_pair are of different address types.
356
    #[test]
357
    fn test_build_recommended_multiple_inputs() -> Result<(), BoxError> {
1✔
358
        let mut psbt = Psbt::from_str(MULTIPARTY_ORIGINAL_PSBT_ONE).unwrap();
1✔
359
        let original_psbt = PARSED_ORIGINAL_PSBT.clone();
1✔
360
        psbt.unsigned_tx.input[2] = original_psbt.unsigned_tx.input[0].clone();
1✔
361
        psbt.inputs[2] = original_psbt.inputs[0].clone();
1✔
362
        let sender = SenderBuilder::new(
1✔
363
            psbt.clone(),
1✔
364
            Uri::try_from("bitcoin:bc1qrmzkzmqcgatutq6nyje8t2qs3mf8t3p0qh3kl2?amount=49.99999890&pjos=0&pj=HTTPS://EXAMPLE.COM/")
1✔
365
                .map_err(|e| format!("{e}"))?
1✔
366
                .assume_checked()
1✔
367
                .check_pj_supported()
1✔
368
                .map_err(|e| format!("{e}"))?,
1✔
369
        )
370
        .build_recommended(FeeRate::MIN);
1✔
371
        assert!(sender.is_ok(), "{:#?}", sender.err());
1✔
372
        assert_eq!(
1✔
373
            sender.unwrap().psbt_ctx.fee_contribution.unwrap().max_amount,
1✔
374
            Amount::from_sat(0)
1✔
375
        );
376

377
        let mut psbt = Psbt::from_str(MULTIPARTY_ORIGINAL_PSBT_ONE).unwrap();
1✔
378
        psbt.unsigned_tx.input.pop();
1✔
379
        psbt.inputs.pop();
1✔
380
        let sender = SenderBuilder::new(
1✔
381
            psbt.clone(),
1✔
382
            Uri::try_from("bitcoin:bc1qrmzkzmqcgatutq6nyje8t2qs3mf8t3p0qh3kl2?amount=49.99999890&pjos=0&pj=HTTPS://EXAMPLE.COM/")
1✔
383
                .map_err(|e| format!("{e}"))?
1✔
384
                .assume_checked()
1✔
385
                .check_pj_supported()
1✔
386
                .map_err(|e| format!("{e}"))?,
1✔
387
        )
388
        .build_recommended(FeeRate::from_sat_per_vb(170000000).expect("Could not determine feerate"));
1✔
389
        assert!(sender.is_ok(), "{:#?}", sender.err());
1✔
390
        assert_eq!(
1✔
391
            sender.unwrap().psbt_ctx.fee_contribution.unwrap().max_amount,
1✔
392
            Amount::from_sat(9999999822)
1✔
393
        );
394

395
        Ok(())
1✔
396
    }
1✔
397

398
    #[test]
399
    fn test_build_recommended_max_fee_contribution() {
1✔
400
        let psbt = PARSED_ORIGINAL_PSBT.clone();
1✔
401
        let sender = SenderBuilder::new(psbt.clone(), pj_uri())
1✔
402
            .build_recommended(
1✔
403
                FeeRate::from_sat_per_vb(2000000).expect("Could not determine feerate"),
1✔
404
            )
405
            .expect("sender should succeed");
1✔
406
        assert_eq!(sender.psbt_ctx.output_substitution, OutputSubstitution::Disabled);
1✔
407
        assert_eq!(&sender.psbt_ctx.payee, &pj_uri().address.script_pubkey());
1✔
408
        let fee_contribution =
1✔
409
            sender.psbt_ctx.fee_contribution.expect("sender should contribute fees");
1✔
410
        assert_eq!(fee_contribution.max_amount, psbt.unsigned_tx.output[0].value);
1✔
411
        assert_eq!(fee_contribution.vout, 0);
1✔
412
        assert_eq!(sender.psbt_ctx.min_fee_rate, FeeRate::from_sat_per_kwu(500000000));
1✔
413
    }
1✔
414

415
    #[test]
416
    fn test_build_recommended() {
1✔
417
        let sender = SenderBuilder::new(PARSED_ORIGINAL_PSBT.clone(), pj_uri())
1✔
418
            .build_recommended(FeeRate::BROADCAST_MIN)
1✔
419
            .expect("sender should succeed");
1✔
420
        assert_eq!(sender.psbt_ctx.output_substitution, OutputSubstitution::Disabled);
1✔
421
        assert_eq!(&sender.psbt_ctx.payee, &pj_uri().address.script_pubkey());
1✔
422
        let fee_contribution =
1✔
423
            sender.psbt_ctx.fee_contribution.expect("sender should contribute fees");
1✔
424
        assert_eq!(fee_contribution.max_amount, Amount::from_sat(91));
1✔
425
        assert_eq!(fee_contribution.vout, 0);
1✔
426
        assert_eq!(sender.psbt_ctx.min_fee_rate, FeeRate::from_sat_per_kwu(250));
1✔
427
        // Ensure the receiver's output substitution preference is respected either way
428
        let mut pj_uri = pj_uri();
1✔
429
        pj_uri.extras.output_substitution = OutputSubstitution::Enabled;
1✔
430
        let sender = SenderBuilder::new(PARSED_ORIGINAL_PSBT.clone(), pj_uri)
1✔
431
            .build_recommended(FeeRate::from_sat_per_vb_unchecked(1))
1✔
432
            .expect("sender should succeed");
1✔
433
        assert_eq!(sender.psbt_ctx.output_substitution, OutputSubstitution::Enabled);
1✔
434
    }
1✔
435

436
    #[test]
437
    fn handle_json_errors() {
1✔
438
        let ctx = create_v1_context();
1✔
439
        let known_json_error = serde_json::json!({
1✔
440
            "errorCode": "version-unsupported",
1✔
441
            "message": "This version of payjoin is not supported."
1✔
442
        })
443
        .to_string();
1✔
444
        match ctx.process_response(known_json_error.as_bytes()) {
1✔
445
            Err(ResponseError::WellKnown(WellKnownError {
446
                code: ErrorCode::VersionUnsupported,
447
                ..
448
            })) => (),
1✔
449
            _ => panic!("Expected WellKnownError"),
×
450
        }
451

452
        let ctx = create_v1_context();
1✔
453
        let invalid_json_error = serde_json::json!({
1✔
454
            "err": "random",
1✔
455
            "message": "This version of payjoin is not supported."
1✔
456
        })
457
        .to_string();
1✔
458
        match ctx.process_response(invalid_json_error.as_bytes()) {
1✔
459
            Err(ResponseError::Validation(_)) => (),
1✔
460
            _ => panic!("Expected unrecognized JSON error"),
×
461
        }
462
    }
1✔
463

464
    #[test]
465
    fn process_response_valid() {
1✔
466
        let ctx = create_v1_context();
1✔
467
        let response = ctx.process_response(PAYJOIN_PROPOSAL.as_bytes());
1✔
468
        assert!(response.is_ok())
1✔
469
    }
1✔
470

471
    #[test]
472
    fn process_response_invalid_psbt() {
1✔
473
        let ctx = create_v1_context();
1✔
474
        let response = ctx.process_response(INVALID_PSBT.as_bytes());
1✔
475
        match response {
1✔
476
            Ok(_) => panic!("Invalid PSBT should have caused an error"),
×
477
            Err(error) => match error {
1✔
478
                ResponseError::Validation(e) => {
1✔
479
                    assert_eq!(
1✔
480
                        e.to_string(),
1✔
481
                        ValidationError::from(InternalValidationError::Parse).to_string()
1✔
482
                    );
483
                }
484
                _ => panic!("Unexpected error type"),
×
485
            },
486
        }
487
    }
1✔
488

489
    #[test]
490
    fn process_response_invalid_utf8() {
1✔
491
        // A PSBT expects an exact match so padding with null bytes for the from_str method is
492
        // invalid
493
        let mut invalid_utf8_padding = PAYJOIN_PROPOSAL.as_bytes().to_vec();
1✔
494
        invalid_utf8_padding
1✔
495
            .extend(std::iter::repeat_n(0x00, MAX_CONTENT_LENGTH - invalid_utf8_padding.len()));
1✔
496

497
        let ctx = create_v1_context();
1✔
498
        let response = ctx.process_response(&invalid_utf8_padding);
1✔
499
        match response {
1✔
500
            Ok(_) => panic!("Invalid UTF-8 should have caused an error"),
×
501
            Err(error) => match error {
1✔
502
                ResponseError::Validation(e) => {
1✔
503
                    assert_eq!(
1✔
504
                        e.to_string(),
1✔
505
                        ValidationError::from(InternalValidationError::Parse).to_string()
1✔
506
                    );
507
                }
508
                _ => panic!("Unexpected error type"),
×
509
            },
510
        }
511
    }
1✔
512

513
    #[test]
514
    fn process_response_invalid_buffer_len() {
1✔
515
        let mut data = PAYJOIN_PROPOSAL.as_bytes().to_vec();
1✔
516
        data.extend(std::iter::repeat_n(0, MAX_CONTENT_LENGTH + 1));
1✔
517

518
        let ctx = create_v1_context();
1✔
519
        let response = ctx.process_response(&data);
1✔
520
        match response {
1✔
521
            Ok(_) => panic!("Invalid buffer length should have caused an error"),
×
522
            Err(error) => match error {
1✔
523
                ResponseError::Validation(e) => {
1✔
524
                    assert_eq!(
1✔
525
                        e.to_string(),
1✔
526
                        ValidationError::from(InternalValidationError::ContentTooLarge).to_string()
1✔
527
                    );
528
                }
529
                _ => panic!("Unexpected error type"),
×
530
            },
531
        }
532
    }
1✔
533

534
    #[test]
535
    fn test_max_content_length() {
1✔
536
        assert_eq!(MAX_CONTENT_LENGTH, 4_000_000 * 4 / 3);
1✔
537
    }
1✔
538

539
    #[test]
540
    fn test_non_witness_input_weight_const() {
1✔
541
        assert_eq!(NON_WITNESS_INPUT_WEIGHT, bitcoin::Weight::from_wu(160));
1✔
542
    }
1✔
543
}
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