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

payjoin / rust-payjoin / 24517552168

16 Apr 2026 03:00PM UTC coverage: 84.74% (+0.4%) from 84.38%
24517552168

Pull #1377

github

web-flow
Merge 6efecbe76 into 7b9057d1f
Pull Request #1377: Use internal Url struct in favor of url::Url to minimize the url dep in payjoin

534 of 574 new or added lines in 16 files covered. (93.03%)

3 existing lines in 2 files now uncovered.

11267 of 13296 relevant lines covered (84.74%)

402.74 hits per line

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

46.99
/payjoin/src/core/receive/error.rs
1
use std::{error, fmt};
2

3
use crate::error_codes::ErrorCode::{
4
    self, NotEnoughMoney, OriginalPsbtRejected, Unavailable, VersionUnsupported,
5
};
6

7
/// The top-level error type for the payjoin receiver
8
#[derive(Debug)]
9
#[non_exhaustive]
10
pub enum Error {
11
    /// Error in underlying protocol function
12
    Protocol(ProtocolError),
13
    /// Error arising due to the specific receiver implementation
14
    ///
15
    /// e.g. database errors, network failures, wallet errors
16
    Implementation(crate::ImplementationError),
17
}
18

19
impl From<&Error> for JsonReply {
20
    fn from(e: &Error) -> Self {
12✔
21
        match e {
12✔
22
            Error::Protocol(e) => e.into(),
7✔
23
            Error::Implementation(_) => JsonReply::new(Unavailable, "Receiver error"),
5✔
24
        }
25
    }
12✔
26
}
27

28
impl From<ProtocolError> for Error {
29
    fn from(e: ProtocolError) -> Self { Error::Protocol(e) }
×
30
}
31

32
impl fmt::Display for Error {
33
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
12✔
34
        match self {
12✔
35
            Error::Protocol(e) => write!(f, "Protocol error: {e}"),
3✔
36
            Error::Implementation(e) => write!(f, "Implementation error: {e}"),
9✔
37
        }
38
    }
12✔
39
}
40

41
impl error::Error for Error {
42
    fn source(&self) -> Option<&(dyn error::Error + 'static)> {
×
43
        match self {
×
44
            Error::Protocol(e) => e.source(),
×
45
            Error::Implementation(e) => e.source(),
×
46
        }
47
    }
×
48
}
49

50
/// The protocol error type for the payjoin receiver, representing failures in
51
/// the internal protocol operation.
52
///
53
/// The error handling is designed to:
54
/// 1. Provide structured error responses for protocol-level failures
55
/// 2. Hide implementation details of external errors for security
56
/// 3. Support proper error propagation through the receiver stack
57
/// 4. Provide errors according to BIP-78 JSON error specifications for return
58
///    after conversion into [`JsonReply`]
59
#[derive(Debug)]
60
pub enum ProtocolError {
61
    /// Error arising from validation of the original PSBT payload
62
    OriginalPayload(PayloadError),
63
    /// Protocol-specific errors for BIP-78 v1 requests (e.g. HTTP request validation, parameter checks)
64
    #[cfg(feature = "v1")]
65
    V1(crate::receive::v1::RequestError),
66
    #[cfg(feature = "v2")]
67
    /// V2-specific errors that are infeasable to reply to the sender
68
    V2(crate::receive::v2::SessionError),
69
}
70

71
/// The standard format for errors that can be replied as JSON.
72
///
73
/// The JSON output includes the following fields:
74
/// ```json
75
/// {
76
///     "errorCode": "specific-error-code",
77
///     "message": "Human readable error message"
78
/// }
79
/// ```
80
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
81
pub struct JsonReply {
82
    /// The error code
83
    error_code: ErrorCode,
84
    /// The error message to be displayed only in debug logs
85
    message: String,
86
    /// Additional fields to be included in the JSON response
87
    extra: serde_json::Map<String, serde_json::Value>,
88
}
89

90
impl JsonReply {
91
    /// Create a new Reply
92
    pub(crate) fn new(error_code: ErrorCode, message: impl fmt::Display) -> Self {
15✔
93
        Self { error_code, message: message.to_string(), extra: serde_json::Map::new() }
15✔
94
    }
15✔
95

96
    /// Add an additional field to the JSON response
97
    pub fn with_extra(mut self, key: &str, value: impl Into<serde_json::Value>) -> Self {
×
98
        self.extra.insert(key.to_string(), value.into());
×
99
        self
×
100
    }
×
101

102
    /// Serialize the Reply to a JSON string
103
    pub fn to_json(&self) -> serde_json::Value {
7✔
104
        let mut map = serde_json::Map::new();
7✔
105
        map.insert("errorCode".to_string(), self.error_code.to_string().into());
7✔
106
        map.insert("message".to_string(), self.message.clone().into());
7✔
107
        map.extend(self.extra.clone());
7✔
108

109
        serde_json::Value::Object(map)
7✔
110
    }
7✔
111

112
    /// Get the HTTP status code for the error
113
    pub fn status_code(&self) -> u16 {
2✔
114
        match self.error_code {
2✔
115
            ErrorCode::Unavailable => http::StatusCode::INTERNAL_SERVER_ERROR,
1✔
116
            ErrorCode::NotEnoughMoney
117
            | ErrorCode::VersionUnsupported
118
            | ErrorCode::OriginalPsbtRejected => http::StatusCode::BAD_REQUEST,
1✔
119
        }
120
        .as_u16()
2✔
121
    }
2✔
122
}
123

124
impl From<&ProtocolError> for JsonReply {
125
    fn from(e: &ProtocolError) -> Self {
7✔
126
        use ProtocolError::*;
127
        match e {
7✔
128
            OriginalPayload(e) => e.into(),
7✔
129
            #[cfg(feature = "v1")]
130
            V1(e) => JsonReply::new(OriginalPsbtRejected, e),
×
131
            #[cfg(feature = "v2")]
132
            V2(_) => JsonReply::new(Unavailable, "Receiver error"),
×
133
        }
134
    }
7✔
135
}
136

137
impl fmt::Display for ProtocolError {
138
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
3✔
139
        match &self {
3✔
140
            Self::OriginalPayload(e) => e.fmt(f),
2✔
141
            #[cfg(feature = "v1")]
142
            Self::V1(e) => e.fmt(f),
×
143
            #[cfg(feature = "v2")]
144
            Self::V2(e) => e.fmt(f),
1✔
145
        }
146
    }
3✔
147
}
148

149
impl error::Error for ProtocolError {
150
    fn source(&self) -> Option<&(dyn error::Error + 'static)> {
×
151
        match &self {
×
152
            Self::OriginalPayload(e) => e.source(),
×
153
            #[cfg(feature = "v1")]
154
            Self::V1(e) => e.source(),
×
155
            #[cfg(feature = "v2")]
156
            Self::V2(e) => e.source(),
×
157
        }
158
    }
×
159
}
160

161
impl From<InternalPayloadError> for Error {
162
    fn from(e: InternalPayloadError) -> Self {
5✔
163
        Error::Protocol(ProtocolError::OriginalPayload(e.into()))
5✔
164
    }
5✔
165
}
166

167
/// An error that occurs during validation of the original PSBT payload sent by the sender.
168
///
169
/// This type provides a public abstraction over internal validation errors while maintaining a stable public API.
170
/// It handles various failure modes like:
171
/// - Invalid UTF-8 encoding
172
/// - PSBT parsing errors
173
/// - BIP-78 specific PSBT validation failures
174
/// - Fee rate validation
175
/// - Input ownership validation
176
/// - Previous transaction output validation
177
///
178
/// The error messages are formatted as JSON strings suitable for HTTP responses according to the BIP-78 spec,
179
/// with appropriate error codes and human-readable messages.
180
#[derive(Debug)]
181
pub struct PayloadError(pub(crate) InternalPayloadError);
182

183
impl From<InternalPayloadError> for PayloadError {
184
    fn from(value: InternalPayloadError) -> Self { PayloadError(value) }
5✔
185
}
186

187
#[derive(Debug)]
188
pub(crate) enum InternalPayloadError {
189
    /// The payload is not valid utf-8
190
    Utf8(std::str::Utf8Error),
191
    /// The payload is not a valid PSBT
192
    ParsePsbt(bitcoin::psbt::PsbtParseError),
193
    /// Invalid sender parameters
194
    SenderParams(super::optional_parameters::Error),
195
    /// The raw PSBT fails bip78-specific validation.
196
    InconsistentPsbt(crate::psbt::InconsistentPsbt),
197
    /// The prevtxout is missing
198
    PrevTxOut(crate::psbt::PrevTxOutError),
199
    /// The Original PSBT has no output for the receiver.
200
    MissingPayment,
201
    /// The original PSBT transaction fails the broadcast check
202
    OriginalPsbtNotBroadcastable,
203
    #[allow(dead_code)]
204
    /// The sender is trying to spend the receiver input
205
    InputOwned(bitcoin::ScriptBuf),
206
    #[allow(dead_code)]
207
    /// Original PSBT input has been seen before. Only automatic receivers, aka "interactive" in the spec
208
    /// look out for these to prevent probing attacks.
209
    InputSeen(bitcoin::OutPoint),
210
    /// Original PSBT fee rate is below minimum fee rate set by the receiver.
211
    ///
212
    /// First argument is the calculated fee rate of the original PSBT.
213
    ///
214
    /// Second argument is the minimum fee rate optionally set by the receiver.
215
    PsbtBelowFeeRate(bitcoin::FeeRate, bitcoin::FeeRate),
216
    /// Effective receiver feerate exceeds maximum allowed feerate
217
    FeeTooHigh(bitcoin::FeeRate, bitcoin::FeeRate),
218
}
219

220
impl From<&PayloadError> for JsonReply {
221
    fn from(e: &PayloadError) -> Self {
8✔
222
        use InternalPayloadError::*;
223

224
        match &e.0 {
8✔
225
            Utf8(_)
226
            | ParsePsbt(_)
227
            | InconsistentPsbt(_)
228
            | PrevTxOut(_)
229
            | MissingPayment
230
            | OriginalPsbtNotBroadcastable
231
            | InputOwned(_)
232
            | InputSeen(_)
233
            | PsbtBelowFeeRate(_, _) => JsonReply::new(OriginalPsbtRejected, e),
8✔
234

235
            FeeTooHigh(_, _) => JsonReply::new(NotEnoughMoney, e),
×
236

237
            SenderParams(e) => match e {
×
238
                super::optional_parameters::Error::UnknownVersion { supported_versions } => {
×
239
                    let supported_versions_json =
×
240
                        serde_json::to_string(supported_versions).unwrap_or_default();
×
241
                    JsonReply::new(VersionUnsupported, "This version of payjoin is not supported.")
×
242
                        .with_extra("supported", supported_versions_json)
×
243
                }
244
                super::optional_parameters::Error::FeeRate =>
245
                    JsonReply::new(OriginalPsbtRejected, e),
×
246
                super::optional_parameters::Error::MalformedQuery =>
NEW
247
                    JsonReply::new(OriginalPsbtRejected, e),
×
248
            },
249
        }
250
    }
8✔
251
}
252

253
impl fmt::Display for PayloadError {
254
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { self.0.fmt(f) }
10✔
255
}
256

257
impl fmt::Display for InternalPayloadError {
258
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
10✔
259
        use InternalPayloadError::*;
260

261
        match &self {
10✔
262
            Utf8(e) => write!(f, "{e}"),
×
263
            ParsePsbt(e) => write!(f, "{e}"),
×
264
            SenderParams(e) => write!(f, "{e}"),
×
265
            InconsistentPsbt(e) => write!(f, "{e}"),
×
266
            PrevTxOut(e) => write!(f, "PrevTxOut Error: {e}"),
×
267
            MissingPayment => write!(f, "Missing payment."),
2✔
268
            OriginalPsbtNotBroadcastable => write!(f, "Can't broadcast. PSBT rejected by mempool."),
5✔
269
            InputOwned(_) => write!(f, "The receiver rejected the original PSBT."),
3✔
270
            InputSeen(_) => write!(f, "The receiver rejected the original PSBT."),
×
271
            PsbtBelowFeeRate(original_psbt_fee_rate, receiver_min_fee_rate) => write!(
×
272
                f,
×
273
                "Original PSBT fee rate too low: {original_psbt_fee_rate} < {receiver_min_fee_rate}."
274
            ),
275
            FeeTooHigh(proposed_fee_rate, max_fee_rate) => write!(
×
276
                f,
×
277
                "Effective receiver feerate exceeds maximum allowed feerate: {proposed_fee_rate} > {max_fee_rate}"
278
            ),
279
        }
280
    }
10✔
281
}
282

283
impl std::error::Error for PayloadError {
284
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
×
285
        use InternalPayloadError::*;
286
        match &self.0 {
×
287
            Utf8(e) => Some(e),
×
288
            ParsePsbt(e) => Some(e),
×
289
            SenderParams(e) => Some(e),
×
290
            InconsistentPsbt(e) => Some(e),
×
291
            PrevTxOut(e) => Some(e),
×
292
            PsbtBelowFeeRate(_, _) => None,
×
293
            FeeTooHigh(_, _) => None,
×
294
            MissingPayment => None,
×
295
            OriginalPsbtNotBroadcastable => None,
×
296
            InputOwned(_) => None,
×
297
            InputSeen(_) => None,
×
298
        }
299
    }
×
300
}
301

302
/// Error that may occur when output substitution fails.
303
///
304
/// This is currently opaque type because we aren't sure which variants will stay.
305
/// You can only display it.
306
#[derive(Debug, PartialEq)]
307
pub struct OutputSubstitutionError(InternalOutputSubstitutionError);
308

309
#[derive(Debug, PartialEq, Eq)]
310
pub(crate) enum InternalOutputSubstitutionError {
311
    /// Output substitution is disabled and output value was decreased
312
    DecreasedValueWhenDisabled,
313
    /// Output substitution is disabled and script pubkey was changed
314
    ScriptPubKeyChangedWhenDisabled,
315
    /// Current output substitution implementation doesn't support reducing the number of outputs
316
    NotEnoughOutputs,
317
    /// The provided drain script could not be identified in the provided replacement outputs
318
    InvalidDrainScript,
319
}
320

321
impl fmt::Display for OutputSubstitutionError {
322
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
×
323
        match &self.0 {
×
324
            InternalOutputSubstitutionError::DecreasedValueWhenDisabled => write!(f, "Decreasing the receiver output value is not allowed when output substitution is disabled"),
×
325
            InternalOutputSubstitutionError::ScriptPubKeyChangedWhenDisabled => write!(f, "Changing the receiver output script pubkey is not allowed when output substitution is disabled"),
×
326
            InternalOutputSubstitutionError::NotEnoughOutputs => write!(
×
327
                f,
×
328
                "Current output substitution implementation doesn't support reducing the number of outputs"
329
            ),
330
            InternalOutputSubstitutionError::InvalidDrainScript =>
331
                write!(f, "The provided drain script could not be identified in the provided replacement outputs"),
×
332
        }
333
    }
×
334
}
335

336
impl From<InternalOutputSubstitutionError> for OutputSubstitutionError {
337
    fn from(value: InternalOutputSubstitutionError) -> Self { OutputSubstitutionError(value) }
4✔
338
}
339

340
impl std::error::Error for OutputSubstitutionError {
341
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
×
342
        match &self.0 {
×
343
            InternalOutputSubstitutionError::DecreasedValueWhenDisabled => None,
×
344
            InternalOutputSubstitutionError::ScriptPubKeyChangedWhenDisabled => None,
×
345
            InternalOutputSubstitutionError::NotEnoughOutputs => None,
×
346
            InternalOutputSubstitutionError::InvalidDrainScript => None,
×
347
        }
348
    }
×
349
}
350

351
/// Error that may occur when coin selection fails.
352
///
353
/// This is currently opaque type because we aren't sure which variants will stay.
354
/// You can only display it.
355
#[derive(Debug, PartialEq, Eq)]
356
pub struct SelectionError(InternalSelectionError);
357

358
#[derive(Debug, PartialEq, Eq)]
359
pub(crate) enum InternalSelectionError {
360
    /// No candidates available for selection
361
    Empty,
362
    /// Current privacy selection implementation only supports 2-output transactions
363
    UnsupportedOutputLength,
364
    /// No selection candidates improve privacy
365
    NotFound,
366
}
367

368
impl fmt::Display for SelectionError {
369
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
×
370
        match &self.0 {
×
371
            InternalSelectionError::Empty => write!(f, "No candidates available for selection"),
×
372
            InternalSelectionError::UnsupportedOutputLength => write!(
×
373
                f,
×
374
                "Current privacy selection implementation only supports 2-output transactions"
375
            ),
376
            InternalSelectionError::NotFound =>
377
                write!(f, "No selection candidates improve privacy"),
×
378
        }
379
    }
×
380
}
381

382
impl error::Error for SelectionError {
383
    fn source(&self) -> Option<&(dyn error::Error + 'static)> {
×
384
        use InternalSelectionError::*;
385

386
        match &self.0 {
×
387
            Empty => None,
×
388
            UnsupportedOutputLength => None,
×
389
            NotFound => None,
×
390
        }
391
    }
×
392
}
393
impl From<InternalSelectionError> for SelectionError {
394
    fn from(value: InternalSelectionError) -> Self { SelectionError(value) }
13✔
395
}
396

397
/// Error that may occur when input contribution fails.
398
///
399
/// This is currently opaque type because we aren't sure which variants will stay.
400
/// You can only display it.
401
#[derive(Debug, PartialEq, Eq)]
402
pub struct InputContributionError(InternalInputContributionError);
403

404
#[derive(Debug, PartialEq, Eq)]
405
pub(crate) enum InternalInputContributionError {
406
    /// Total input value is not enough to cover additional output value
407
    ValueTooLow,
408
    /// Duplicate input detected. The same outpoint is already present in the transaction
409
    DuplicateInput(bitcoin::OutPoint),
410
}
411

412
impl fmt::Display for InputContributionError {
413
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
×
414
        match &self.0 {
×
415
            InternalInputContributionError::ValueTooLow =>
416
                write!(f, "Total input value is not enough to cover additional output value"),
×
417
            InternalInputContributionError::DuplicateInput(outpoint) =>
×
418
                write!(f, "Duplicate input detected: {outpoint}"),
×
419
        }
420
    }
×
421
}
422

423
impl error::Error for InputContributionError {
424
    fn source(&self) -> Option<&(dyn error::Error + 'static)> {
×
425
        match &self.0 {
×
426
            InternalInputContributionError::ValueTooLow => None,
×
427
            InternalInputContributionError::DuplicateInput(_) => None,
×
428
        }
429
    }
×
430
}
431

432
impl From<InternalInputContributionError> for InputContributionError {
433
    fn from(value: InternalInputContributionError) -> Self { InputContributionError(value) }
2✔
434
}
435

436
#[cfg(test)]
437
mod tests {
438
    use super::*;
439
    use crate::ImplementationError;
440

441
    #[test]
442
    fn test_json_reply_from_implementation_error() {
1✔
443
        struct AlwaysPanics;
444

445
        impl fmt::Display for AlwaysPanics {
446
            fn fmt(&self, _f: &mut fmt::Formatter<'_>) -> fmt::Result {
×
447
                panic!("internal error should never display when converting to JsonReply");
×
448
            }
449
        }
450

451
        impl fmt::Debug for AlwaysPanics {
452
            fn fmt(&self, _f: &mut fmt::Formatter<'_>) -> fmt::Result {
×
453
                panic!("internal error should never debug when converting to JsonReply");
×
454
            }
455
        }
456

457
        impl error::Error for AlwaysPanics {
458
            fn source(&self) -> Option<&(dyn error::Error + 'static)> {
×
459
                panic!("internal error should never be examined when converting to JsonReply");
×
460
            }
461
        }
462
        // Use a panicking error to ensure conversion does not touch internal formatting
463
        let internal = AlwaysPanics;
1✔
464
        let error = Error::Implementation(ImplementationError::new(internal));
1✔
465
        let reply = JsonReply::from(&error);
1✔
466
        let expected = JsonReply {
1✔
467
            error_code: ErrorCode::Unavailable,
1✔
468
            message: "Receiver error".to_string(),
1✔
469
            extra: serde_json::Map::new(),
1✔
470
        };
1✔
471
        assert_eq!(reply, expected);
1✔
472

473
        let json = reply.to_json();
1✔
474
        assert_eq!(
1✔
475
            json,
476
            serde_json::json!({
1✔
477
                "errorCode": ErrorCode::Unavailable.to_string(),
1✔
478
                "message": "Receiver error",
1✔
479
            })
480
        );
481
    }
1✔
482

483
    #[test]
484
    /// Create an implementation error that returns INTERNAL_SERVER_ERROR
485
    fn test_json_reply_with_500_status_code() {
1✔
486
        let error = Error::Implementation(ImplementationError::from("test error"));
1✔
487
        let reply = JsonReply::from(&error);
1✔
488

489
        assert_eq!(reply.status_code(), http::StatusCode::INTERNAL_SERVER_ERROR.as_u16());
1✔
490

491
        let json = reply.to_json();
1✔
492
        assert_eq!(json["errorCode"], "unavailable");
1✔
493
        assert_eq!(json["message"], "Receiver error");
1✔
494
    }
1✔
495

496
    #[test]
497
    /// Create a payload error that returns BAD_REQUEST
498
    fn test_json_reply_with_400_status_code() {
1✔
499
        let payload_error = PayloadError(InternalPayloadError::MissingPayment);
1✔
500
        let error = Error::Protocol(ProtocolError::OriginalPayload(payload_error));
1✔
501
        let reply = JsonReply::from(&error);
1✔
502

503
        assert_eq!(reply.status_code(), http::StatusCode::BAD_REQUEST.as_u16());
1✔
504

505
        let json = reply.to_json();
1✔
506
        assert_eq!(json["errorCode"], "original-psbt-rejected");
1✔
507
        assert_eq!(json["message"], "Missing payment.");
1✔
508
    }
1✔
509
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc