Skip to main content

mpp/
error.rs

1//! Error types for the mpp library.
2//!
3//! This module provides:
4//! - [`MppError`]: The main error enum for all mpp operations
5//! - [`PaymentErrorDetails`]: RFC 9457 Problem Details format for HTTP error responses
6//! - [`PaymentError`]: Trait for converting errors to Problem Details
7
8use thiserror::Error;
9
10/// Result type alias for mpp operations.
11pub type Result<T> = std::result::Result<T, MppError>;
12
13// ==================== RFC 9457 Problem Details ====================
14
15/// Base URI for core payment-auth problem types.
16pub const CORE_PROBLEM_TYPE_BASE: &str = "https://paymentauth.org/problems";
17
18/// Base URI for session/channel problem types.
19pub const SESSION_PROBLEM_TYPE_BASE: &str = "https://paymentauth.org/problems/session";
20
21/// Deprecated: use [`SESSION_PROBLEM_TYPE_BASE`] instead. Remove in next major version.
22#[deprecated(since = "0.5.0", note = "renamed to SESSION_PROBLEM_TYPE_BASE")]
23pub const STREAM_PROBLEM_TYPE_BASE: &str = SESSION_PROBLEM_TYPE_BASE;
24
25/// Problem type suffix for server-side failures.
26pub(crate) const INTERNAL_PROBLEM_TYPE_SUFFIX: &str = "internal-payment-error";
27
28/// Detail reported for every `internal-payment-error` problem. The underlying
29/// error can carry RPC URLs or store paths, so it is never sent to clients.
30const INTERNAL_PROBLEM_DETAIL: &str = "An internal payment error occurred.";
31
32/// Default hint for payment-required errors.
33pub const HINT_PAYMENT_REQUIRED: &str = "Use a supported wallet to pay for this resource using one of the supported payment methods returned in the WWW-Authenticate header. See https://mpp.dev/tools/wallet.md";
34
35/// Default hint for malformed-credential errors.
36pub const HINT_MALFORMED_CREDENTIAL: &str = "Use a supported wallet to construct valid credentials for one of the supported payment methods returned in the WWW-Authenticate header. See https://mpp.dev/tools/wallet.md";
37
38/// Default hint for method-unsupported errors.
39pub const HINT_METHOD_UNSUPPORTED: &str = HINT_PAYMENT_REQUIRED;
40
41/// RFC 9457 Problem Details structure for payment errors.
42///
43/// This struct provides a standardized format for HTTP error responses,
44/// following [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html).
45///
46/// # Example
47///
48/// ```
49/// use mpp::error::PaymentErrorDetails;
50///
51/// let problem = PaymentErrorDetails::core("verification-failed")
52///     .with_title("VerificationFailedError")
53///     .with_status(402)
54///     .with_detail("Payment verification failed: insufficient amount.");
55///
56/// // Serialize to JSON for HTTP response body
57/// let json = serde_json::to_string(&problem).unwrap();
58/// ```
59#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
60pub struct PaymentErrorDetails {
61    /// A URI reference that identifies the problem type.
62    #[serde(rename = "type")]
63    pub problem_type: String,
64
65    /// A short, human-readable summary of the problem type.
66    pub title: String,
67
68    /// The HTTP status code for this problem.
69    pub status: u16,
70
71    /// A human-readable explanation specific to this occurrence.
72    pub detail: String,
73
74    /// The challenge ID associated with this error, if applicable.
75    #[serde(rename = "challengeId", skip_serializing_if = "Option::is_none")]
76    pub challenge_id: Option<String>,
77
78    /// An optional hint providing guidance on how to resolve this error.
79    /// This is an RFC 9457 extension member.
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub hint: Option<String>,
82}
83
84impl PaymentErrorDetails {
85    /// Create a new PaymentErrorDetails with a full problem type URI.
86    pub fn new(type_uri: impl Into<String>) -> Self {
87        Self {
88            problem_type: type_uri.into(),
89            title: String::new(),
90            status: 402,
91            detail: String::new(),
92            challenge_id: None,
93            hint: None,
94        }
95    }
96
97    /// Create a PaymentErrorDetails for a core payment-auth problem.
98    ///
99    /// The full type URI will be `{CORE_PROBLEM_TYPE_BASE}/{suffix}`.
100    pub fn core(suffix: impl std::fmt::Display) -> Self {
101        Self::new(format!("{}/{}", CORE_PROBLEM_TYPE_BASE, suffix))
102    }
103
104    /// Create a PaymentErrorDetails for a session/channel problem.
105    ///
106    /// The full type URI will be `{SESSION_PROBLEM_TYPE_BASE}/{suffix}`.
107    pub fn session(suffix: impl std::fmt::Display) -> Self {
108        Self::new(format!("{}/{}", SESSION_PROBLEM_TYPE_BASE, suffix))
109    }
110
111    /// Deprecated: use [`PaymentErrorDetails::session`] instead. Remove in next major version.
112    #[deprecated(since = "0.5.0", note = "renamed to session()")]
113    pub fn stream(suffix: impl std::fmt::Display) -> Self {
114        Self::session(suffix)
115    }
116
117    /// Set the title.
118    pub fn with_title(mut self, title: impl Into<String>) -> Self {
119        self.title = title.into();
120        self
121    }
122
123    /// Set the HTTP status code.
124    pub fn with_status(mut self, status: u16) -> Self {
125        self.status = status;
126        self
127    }
128
129    /// Set the detail message.
130    pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
131        self.detail = detail.into();
132        self
133    }
134
135    /// Set the associated challenge ID.
136    pub fn with_challenge_id(mut self, id: impl Into<String>) -> Self {
137        self.challenge_id = Some(id.into());
138        self
139    }
140
141    /// Set the hint message providing guidance on how to resolve this error.
142    pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
143        self.hint = Some(hint.into());
144        self
145    }
146}
147
148/// Trait for errors that can be converted to RFC 9457 Problem Details.
149///
150/// Implement this trait to enable automatic conversion of payment errors
151/// to standardized HTTP error responses.
152///
153/// # Example
154///
155/// ```
156/// use mpp::error::{PaymentError, PaymentErrorDetails};
157///
158/// struct MyError {
159///     reason: String,
160/// }
161///
162/// impl PaymentError for MyError {
163///     fn to_problem_details(&self, challenge_id: Option<&str>) -> PaymentErrorDetails {
164///         PaymentErrorDetails::core("my-error")
165///             .with_title("MyError")
166///             .with_status(402)
167///             .with_detail(&self.reason)
168///     }
169/// }
170/// ```
171pub trait PaymentError {
172    /// Convert this error to RFC 9457 Problem Details format.
173    ///
174    /// # Arguments
175    ///
176    /// * `challenge_id` - Optional challenge ID to include in the response
177    fn to_problem_details(&self, challenge_id: Option<&str>) -> PaymentErrorDetails;
178}
179
180#[derive(Error, Debug)]
181pub enum MppError {
182    /// Required amount exceeds user's maximum allowed
183    #[error("Required amount ({required}) exceeds maximum allowed ({max})")]
184    AmountExceedsMax { required: u128, max: u128 },
185
186    /// Invalid amount format
187    #[error("Invalid amount: {0}")]
188    InvalidAmount(String),
189
190    /// Configuration is invalid
191    #[error("Invalid configuration: {0}")]
192    InvalidConfig(String),
193
194    // ==================== HTTP Errors ====================
195    /// HTTP request/response error
196    #[error("HTTP error: {0}")]
197    Http(String),
198
199    /// Chain ID mismatch between the expected (pinned) chain and the one found
200    #[error("Chain ID mismatch: expected {expected}, got {got}")]
201    ChainIdMismatch { expected: u64, got: u64 },
202
203    /// JSON serialization/deserialization error
204    #[error("JSON error: {0}")]
205    Json(#[from] serde_json::Error),
206
207    /// Hex decoding error
208    #[cfg(feature = "utils")]
209    #[error("Hex decoding error: {0}")]
210    HexDecode(#[from] hex::FromHexError),
211
212    /// Base64 decoding error
213    #[cfg(feature = "utils")]
214    #[error("Base64 decoding error: {0}")]
215    Base64Decode(#[from] base64::DecodeError),
216
217    // ==================== Web Payment Auth Errors ====================
218    /// Unsupported payment method
219    #[error("Unsupported payment method: {0}")]
220    UnsupportedPaymentMethod(String),
221
222    /// Missing required header
223    #[error("Missing required header: {0}")]
224    MissingHeader(String),
225
226    /// Invalid base64url encoding
227    #[error("Invalid base64url: {0}")]
228    InvalidBase64Url(String),
229
230    /// Receipt is malformed (invalid base64url, bad JSON structure).
231    #[error("Receipt is invalid: {0}.")]
232    InvalidReceipt(String),
233
234    /// Server-side failure while processing a payment (RPC, store, upstream
235    /// API).
236    ///
237    /// Reported to clients as `internal-payment-error` with a fixed detail;
238    /// the message is only part of this error's `Display` output.
239    #[error("Internal payment error: {0}")]
240    Internal(String),
241
242    // ==================== RFC 9457 Payment Problems ====================
243    // These variants can be converted to RFC 9457 Problem Details format.
244    /// Credential is malformed (invalid base64url, bad JSON structure).
245    #[error("{}", format_malformed_credential(.0))]
246    MalformedCredential(Option<String>),
247
248    /// Challenge ID is unknown, expired, or already used.
249    #[error("{}", format_invalid_challenge(.id, .reason))]
250    InvalidChallenge {
251        id: Option<String>,
252        reason: Option<String>,
253    },
254
255    /// Payment proof is invalid or verification failed.
256    #[error("{}", format_verification_failed(.0))]
257    VerificationFailed(Option<String>),
258
259    /// Payment has expired.
260    #[error("{}", format_payment_expired(.0))]
261    PaymentExpired(Option<String>),
262
263    /// No credential was provided but payment is required.
264    #[error("{}", format_payment_required(.realm, .description))]
265    PaymentRequired {
266        realm: Option<String>,
267        description: Option<String>,
268    },
269
270    /// Credential payload does not match the expected schema.
271    #[error("{}", format_invalid_payload(.0))]
272    InvalidPayload(Option<String>),
273
274    /// Request is malformed or contains invalid parameters.
275    #[error("{}", format_bad_request(.0))]
276    BadRequest(Option<String>),
277
278    /// Payment requires additional action (e.g., 3D Secure confirmation).
279    #[error("{}", format_payment_action_required(.0))]
280    PaymentActionRequired(Option<String>),
281
282    /// Payment amount is insufficient for the requested resource.
283    #[error("{}", format_payment_insufficient(.0))]
284    PaymentInsufficient(Option<String>),
285
286    // ==================== Session/Channel Errors ====================
287    /// Insufficient balance in payment channel.
288    #[error("{}", format_insufficient_balance(.0))]
289    InsufficientBalance(Option<String>),
290
291    /// Invalid cryptographic signature.
292    #[error("{}", format_invalid_signature(.0))]
293    InvalidSignature(Option<String>),
294
295    /// Signer does not match expected address.
296    #[error("{}", format_signer_mismatch(.0))]
297    SignerMismatch(Option<String>),
298
299    /// Voucher amount exceeds channel deposit.
300    #[error("{}", format_amount_exceeds_deposit(.0))]
301    AmountExceedsDeposit(Option<String>),
302
303    /// Voucher delta is below the minimum threshold.
304    #[error("{}", format_delta_too_small(.0))]
305    DeltaTooSmall(Option<String>),
306
307    /// Payment channel not found.
308    #[error("{}", format_channel_not_found(.0))]
309    ChannelNotFound(Option<String>),
310
311    /// Payment channel has been closed.
312    #[error("{}", format_channel_closed(.0))]
313    ChannelClosed(Option<String>),
314
315    // ==================== Method-Specific Client Errors ====================
316    /// Tempo-specific client error (transaction reverts, keychain issues, etc.).
317    ///
318    /// See [`crate::client::tempo::TempoClientError`] for variants.
319    #[cfg(all(feature = "client", feature = "tempo"))]
320    #[error("{0}")]
321    Tempo(#[from] crate::client::tempo::TempoClientError),
322
323    // ==================== External Library Errors ====================
324    /// IO error
325    #[error("IO error: {0}")]
326    Io(#[from] std::io::Error),
327
328    /// Invalid UTF-8 in response
329    #[error("Invalid UTF-8 in response body")]
330    InvalidUtf8(#[from] std::string::FromUtf8Error),
331
332    /// System time error
333    #[error("System time error: {0}")]
334    SystemTime(#[from] std::time::SystemTimeError),
335}
336
337// ==================== Result Extension Trait ====================
338
339/// Extension trait for mapping errors into [`MppError`] with a contextual message.
340pub(crate) trait ResultExt<T> {
341    /// Map the error into [`MppError::Http`] with the given context prefix.
342    fn mpp_http(self, context: &str) -> std::result::Result<T, MppError>;
343
344    /// Map the error into [`MppError::InvalidConfig`] with the given context prefix.
345    fn mpp_config(self, context: &str) -> std::result::Result<T, MppError>;
346}
347
348impl<T, E: std::fmt::Display> ResultExt<T> for std::result::Result<T, E> {
349    fn mpp_http(self, context: &str) -> std::result::Result<T, MppError> {
350        self.map_err(|e| MppError::Http(format!("{context}: {e}")))
351    }
352
353    fn mpp_config(self, context: &str) -> std::result::Result<T, MppError> {
354        self.map_err(|e| MppError::InvalidConfig(format!("{context}: {e}")))
355    }
356}
357
358// ==================== RFC 9457 Format Helpers ====================
359
360fn format_malformed_credential(reason: &Option<String>) -> String {
361    match reason {
362        Some(r) => format!("Credential is malformed: {}.", r),
363        None => "Credential is malformed.".to_string(),
364    }
365}
366
367fn format_invalid_challenge(id: &Option<String>, reason: &Option<String>) -> String {
368    let id_part = id
369        .as_ref()
370        .map(|id| format!(" \"{}\"", id))
371        .unwrap_or_default();
372    let reason_part = reason
373        .as_ref()
374        .map(|r| format!(": {}", r))
375        .unwrap_or_default();
376    format!("Challenge{} is invalid{}.", id_part, reason_part)
377}
378
379fn format_verification_failed(reason: &Option<String>) -> String {
380    match reason {
381        Some(r) => format!("Payment verification failed: {}.", r),
382        None => "Payment verification failed.".to_string(),
383    }
384}
385
386fn format_payment_expired(expires: &Option<String>) -> String {
387    match expires {
388        Some(e) => format!("Payment expired at {}.", e),
389        None => "Payment has expired.".to_string(),
390    }
391}
392
393fn format_payment_required(realm: &Option<String>, description: &Option<String>) -> String {
394    let mut s = "Payment is required".to_string();
395    if let Some(r) = realm {
396        s.push_str(&format!(" for \"{}\"", r));
397    }
398    if let Some(d) = description {
399        s.push_str(&format!(" ({})", d));
400    }
401    s.push('.');
402    s
403}
404
405fn format_invalid_payload(reason: &Option<String>) -> String {
406    match reason {
407        Some(r) => format!("Credential payload is invalid: {}.", r),
408        None => "Credential payload is invalid.".to_string(),
409    }
410}
411
412fn format_bad_request(reason: &Option<String>) -> String {
413    match reason {
414        Some(r) => format!("Bad request: {}.", r),
415        None => "Bad request.".to_string(),
416    }
417}
418
419fn format_payment_action_required(reason: &Option<String>) -> String {
420    match reason {
421        Some(r) => format!("Payment requires action: {}.", r),
422        None => "Payment requires action.".to_string(),
423    }
424}
425
426fn format_payment_insufficient(reason: &Option<String>) -> String {
427    match reason {
428        Some(r) => format!("Payment insufficient: {}.", r),
429        None => "Payment amount is insufficient.".to_string(),
430    }
431}
432
433fn format_insufficient_balance(reason: &Option<String>) -> String {
434    match reason {
435        Some(r) => format!("Insufficient balance: {}.", r),
436        None => "Insufficient balance.".to_string(),
437    }
438}
439
440fn format_invalid_signature(reason: &Option<String>) -> String {
441    match reason {
442        Some(r) => format!("Invalid signature: {}.", r),
443        None => "Invalid signature.".to_string(),
444    }
445}
446
447fn format_signer_mismatch(reason: &Option<String>) -> String {
448    match reason {
449        Some(r) => format!("Signer mismatch: {}.", r),
450        None => "Signer is not authorized for this channel.".to_string(),
451    }
452}
453
454fn format_amount_exceeds_deposit(reason: &Option<String>) -> String {
455    match reason {
456        Some(r) => format!("Amount exceeds deposit: {}.", r),
457        None => "Voucher amount exceeds channel deposit.".to_string(),
458    }
459}
460
461fn format_delta_too_small(reason: &Option<String>) -> String {
462    match reason {
463        Some(r) => format!("Delta too small: {}.", r),
464        None => "Amount increase below minimum voucher delta.".to_string(),
465    }
466}
467
468fn format_channel_not_found(reason: &Option<String>) -> String {
469    match reason {
470        Some(r) => format!("Channel not found: {}.", r),
471        None => "No channel with this ID exists.".to_string(),
472    }
473}
474
475fn format_channel_closed(reason: &Option<String>) -> String {
476    match reason {
477        Some(r) => format!("Channel closed: {}.", r),
478        None => "Channel is closed.".to_string(),
479    }
480}
481
482impl MppError {
483    /// Create an unsupported payment method error
484    pub fn unsupported_method(method: &impl std::fmt::Display) -> Self {
485        Self::UnsupportedPaymentMethod(format!("Payment method '{}' is not supported", method))
486    }
487
488    // ==================== RFC 9457 Payment Problem Constructors ====================
489
490    /// Create a malformed credential error.
491    pub fn malformed_credential(reason: impl Into<String>) -> Self {
492        Self::MalformedCredential(Some(reason.into()))
493    }
494
495    /// Create a malformed credential error without a reason.
496    pub fn malformed_credential_default() -> Self {
497        Self::MalformedCredential(None)
498    }
499
500    /// Create an invalid challenge error with ID.
501    pub fn invalid_challenge_id(id: impl Into<String>) -> Self {
502        Self::InvalidChallenge {
503            id: Some(id.into()),
504            reason: None,
505        }
506    }
507
508    /// Create an invalid challenge error with reason.
509    pub fn invalid_challenge_reason(reason: impl Into<String>) -> Self {
510        Self::InvalidChallenge {
511            id: None,
512            reason: Some(reason.into()),
513        }
514    }
515
516    /// Create an invalid challenge error with ID and reason.
517    pub fn invalid_challenge(id: impl Into<String>, reason: impl Into<String>) -> Self {
518        Self::InvalidChallenge {
519            id: Some(id.into()),
520            reason: Some(reason.into()),
521        }
522    }
523
524    /// Create an invalid challenge error without details.
525    pub fn invalid_challenge_default() -> Self {
526        Self::InvalidChallenge {
527            id: None,
528            reason: None,
529        }
530    }
531
532    /// Create a verification failed error.
533    pub fn verification_failed(reason: impl Into<String>) -> Self {
534        Self::VerificationFailed(Some(reason.into()))
535    }
536
537    /// Create a verification failed error without a reason.
538    pub fn verification_failed_default() -> Self {
539        Self::VerificationFailed(None)
540    }
541
542    /// Create a payment expired error with expiration timestamp.
543    pub fn payment_expired(expires: impl Into<String>) -> Self {
544        Self::PaymentExpired(Some(expires.into()))
545    }
546
547    /// Create a payment expired error without timestamp.
548    pub fn payment_expired_default() -> Self {
549        Self::PaymentExpired(None)
550    }
551
552    /// Create a payment required error with realm.
553    pub fn payment_required_realm(realm: impl Into<String>) -> Self {
554        Self::PaymentRequired {
555            realm: Some(realm.into()),
556            description: None,
557        }
558    }
559
560    /// Create a payment required error with description.
561    pub fn payment_required_description(description: impl Into<String>) -> Self {
562        Self::PaymentRequired {
563            realm: None,
564            description: Some(description.into()),
565        }
566    }
567
568    /// Create a payment required error with realm and description.
569    pub fn payment_required(realm: impl Into<String>, description: impl Into<String>) -> Self {
570        Self::PaymentRequired {
571            realm: Some(realm.into()),
572            description: Some(description.into()),
573        }
574    }
575
576    /// Create a payment required error without details.
577    pub fn payment_required_default() -> Self {
578        Self::PaymentRequired {
579            realm: None,
580            description: None,
581        }
582    }
583
584    /// Create an invalid payload error.
585    pub fn invalid_payload(reason: impl Into<String>) -> Self {
586        Self::InvalidPayload(Some(reason.into()))
587    }
588
589    /// Create an invalid payload error without a reason.
590    pub fn invalid_payload_default() -> Self {
591        Self::InvalidPayload(None)
592    }
593
594    /// Create a bad request error.
595    pub fn bad_request(reason: impl Into<String>) -> Self {
596        Self::BadRequest(Some(reason.into()))
597    }
598
599    /// Create a bad request error without a reason.
600    pub fn bad_request_default() -> Self {
601        Self::BadRequest(None)
602    }
603
604    /// Create a payment action required error.
605    pub fn payment_action_required(reason: impl Into<String>) -> Self {
606        Self::PaymentActionRequired(Some(reason.into()))
607    }
608
609    /// Create a payment action required error without a reason.
610    pub fn payment_action_required_default() -> Self {
611        Self::PaymentActionRequired(None)
612    }
613
614    /// Create a payment insufficient error.
615    pub fn payment_insufficient(reason: impl Into<String>) -> Self {
616        Self::PaymentInsufficient(Some(reason.into()))
617    }
618
619    /// Create a payment insufficient error without a reason.
620    pub fn payment_insufficient_default() -> Self {
621        Self::PaymentInsufficient(None)
622    }
623
624    /// Returns the RFC 9457 problem type suffix if this is a payment problem.
625    pub fn problem_type_suffix(&self) -> Option<&'static str> {
626        match self {
627            Self::MalformedCredential(_) => Some("malformed-credential"),
628            Self::InvalidChallenge { .. } => Some("invalid-challenge"),
629            Self::VerificationFailed(_) => Some("verification-failed"),
630            Self::PaymentExpired(_) => Some("payment-expired"),
631            Self::PaymentRequired { .. } => Some("payment-required"),
632            Self::InvalidPayload(_) => Some("invalid-payload"),
633            Self::BadRequest(_) => Some("bad-request"),
634            Self::UnsupportedPaymentMethod(_) => Some("method-unsupported"),
635            Self::PaymentActionRequired(_) => Some("payment-action-required"),
636            Self::PaymentInsufficient(_) => Some("payment-insufficient"),
637            Self::InsufficientBalance(_) => Some("session/insufficient-balance"),
638            Self::InvalidSignature(_) => Some("session/invalid-signature"),
639            Self::SignerMismatch(_) => Some("session/signer-mismatch"),
640            Self::AmountExceedsDeposit(_) => Some("session/amount-exceeds-deposit"),
641            Self::DeltaTooSmall(_) => Some("session/delta-too-small"),
642            Self::ChannelNotFound(_) => Some("session/channel-not-found"),
643            Self::ChannelClosed(_) => Some("session/channel-finalized"),
644            _ => None,
645        }
646    }
647
648    /// Returns true if this error is an RFC 9457 payment problem.
649    pub fn is_payment_problem(&self) -> bool {
650        self.problem_type_suffix().is_some()
651    }
652}
653
654impl PaymentError for MppError {
655    fn to_problem_details(&self, challenge_id: Option<&str>) -> PaymentErrorDetails {
656        let mut problem = match self {
657            // Core payment-auth errors
658            Self::MalformedCredential(_) => PaymentErrorDetails::core("malformed-credential")
659                .with_title("MalformedCredentialError")
660                .with_status(402)
661                .with_hint(HINT_MALFORMED_CREDENTIAL),
662            Self::InvalidChallenge { .. } => PaymentErrorDetails::core("invalid-challenge")
663                .with_title("InvalidChallengeError")
664                .with_status(402),
665            Self::VerificationFailed(_) => PaymentErrorDetails::core("verification-failed")
666                .with_title("VerificationFailedError")
667                .with_status(402),
668            Self::PaymentExpired(_) => PaymentErrorDetails::core("payment-expired")
669                .with_title("PaymentExpiredError")
670                .with_status(402),
671            Self::PaymentRequired { .. } => PaymentErrorDetails::core("payment-required")
672                .with_title("PaymentRequiredError")
673                .with_status(402)
674                .with_hint(HINT_PAYMENT_REQUIRED),
675            Self::InvalidPayload(_) => PaymentErrorDetails::core("invalid-payload")
676                .with_title("InvalidPayloadError")
677                .with_status(402),
678            Self::BadRequest(_) => PaymentErrorDetails::core("bad-request")
679                .with_title("BadRequestError")
680                .with_status(400),
681            Self::UnsupportedPaymentMethod(_) => PaymentErrorDetails::core("method-unsupported")
682                .with_title("PaymentMethodUnsupportedError")
683                .with_status(400)
684                .with_hint(HINT_METHOD_UNSUPPORTED),
685            Self::PaymentActionRequired(_) => PaymentErrorDetails::core("payment-action-required")
686                .with_title("PaymentActionRequiredError")
687                .with_status(402),
688            Self::PaymentInsufficient(_) => PaymentErrorDetails::core("payment-insufficient")
689                .with_title("PaymentInsufficientError")
690                .with_status(402),
691            // Session/channel errors
692            Self::InsufficientBalance(_) => PaymentErrorDetails::session("insufficient-balance")
693                .with_title("InsufficientBalanceError")
694                .with_status(402),
695            Self::InvalidSignature(_) => PaymentErrorDetails::session("invalid-signature")
696                .with_title("InvalidSignatureError")
697                .with_status(402),
698            Self::SignerMismatch(_) => PaymentErrorDetails::session("signer-mismatch")
699                .with_title("SignerMismatchError")
700                .with_status(402),
701            Self::AmountExceedsDeposit(_) => PaymentErrorDetails::session("amount-exceeds-deposit")
702                .with_title("AmountExceedsDepositError")
703                .with_status(402),
704            Self::DeltaTooSmall(_) => PaymentErrorDetails::session("delta-too-small")
705                .with_title("DeltaTooSmallError")
706                .with_status(402),
707            Self::ChannelNotFound(_) => PaymentErrorDetails::session("channel-not-found")
708                .with_title("ChannelNotFoundError")
709                .with_status(410),
710            Self::ChannelClosed(_) => PaymentErrorDetails::session("channel-finalized")
711                .with_title("ChannelClosedError")
712                .with_status(410),
713            // Errors that are not payment problems are the server's own
714            // failures, reported as the spec's internal-payment-error.
715            _ => PaymentErrorDetails::core(INTERNAL_PROBLEM_TYPE_SUFFIX)
716                .with_title("InternalPaymentError")
717                .with_status(500),
718        }
719        .with_detail(if self.is_payment_problem() {
720            self.to_string()
721        } else {
722            INTERNAL_PROBLEM_DETAIL.to_string()
723        });
724
725        // Use embedded challenge ID from InvalidChallenge, or the provided one
726        let embedded_id = match self {
727            Self::InvalidChallenge { id, .. } => id.as_deref(),
728            _ => None,
729        };
730        if let Some(id) = challenge_id.or(embedded_id) {
731            problem = problem.with_challenge_id(id);
732        }
733        problem
734    }
735}
736
737#[cfg(test)]
738mod tests {
739    use super::*;
740
741    #[test]
742    fn test_amount_exceeds_max_display() {
743        let err = MppError::AmountExceedsMax {
744            required: 1000,
745            max: 500,
746        };
747        let display = err.to_string();
748        assert!(display.contains("Required amount (1000) exceeds maximum allowed (500)"));
749    }
750
751    #[test]
752    fn test_invalid_amount_display() {
753        let err = MppError::InvalidAmount("not a number".to_string());
754        assert_eq!(err.to_string(), "Invalid amount: not a number");
755    }
756
757    #[test]
758    fn test_invalid_config_display() {
759        let err = MppError::InvalidConfig("invalid rpc url".to_string());
760        assert_eq!(err.to_string(), "Invalid configuration: invalid rpc url");
761    }
762
763    #[test]
764    fn test_http_display() {
765        let err = MppError::Http("404 Not Found".to_string());
766        assert_eq!(err.to_string(), "HTTP error: 404 Not Found");
767    }
768
769    #[test]
770    fn test_unsupported_payment_method_display() {
771        let err = MppError::UnsupportedPaymentMethod("bitcoin".to_string());
772        assert_eq!(err.to_string(), "Unsupported payment method: bitcoin");
773    }
774
775    #[test]
776    fn test_invalid_challenge_display() {
777        let err = MppError::invalid_challenge_reason("Malformed challenge");
778        assert_eq!(
779            err.to_string(),
780            "Challenge is invalid: Malformed challenge."
781        );
782    }
783
784    #[test]
785    fn test_missing_header_display() {
786        let err = MppError::MissingHeader("WWW-Authenticate".to_string());
787        assert_eq!(err.to_string(), "Missing required header: WWW-Authenticate");
788    }
789
790    #[test]
791    fn test_invalid_base64_url_display() {
792        let err = MppError::InvalidBase64Url("Invalid padding".to_string());
793        assert_eq!(err.to_string(), "Invalid base64url: Invalid padding");
794    }
795
796    #[test]
797    fn test_challenge_expired_display() {
798        let err = MppError::payment_expired("2025-01-15T12:00:00Z");
799        assert_eq!(err.to_string(), "Payment expired at 2025-01-15T12:00:00Z.");
800    }
801
802    #[test]
803    fn test_unsupported_method_constructor() {
804        let err = MppError::unsupported_method(&"bitcoin");
805        assert!(matches!(err, MppError::UnsupportedPaymentMethod(_)));
806        assert!(err.to_string().contains("bitcoin"));
807        assert!(err.to_string().contains("not supported"));
808    }
809
810    // ==================== RFC 9457 Problem Details Tests ====================
811
812    #[test]
813    fn test_problem_details_core() {
814        let problem = PaymentErrorDetails::core("test-error")
815            .with_title("TestError")
816            .with_status(400)
817            .with_detail("Something went wrong");
818
819        assert_eq!(
820            problem.problem_type,
821            "https://paymentauth.org/problems/test-error"
822        );
823        assert_eq!(problem.title, "TestError");
824        assert_eq!(problem.status, 400);
825        assert_eq!(problem.detail, "Something went wrong");
826        assert!(problem.challenge_id.is_none());
827    }
828
829    #[test]
830    fn test_problem_details_session() {
831        let problem = PaymentErrorDetails::session("insufficient-balance")
832            .with_title("InsufficientBalanceError")
833            .with_status(402)
834            .with_detail("Insufficient balance.");
835
836        assert_eq!(
837            problem.problem_type,
838            "https://paymentauth.org/problems/session/insufficient-balance"
839        );
840        assert_eq!(problem.title, "InsufficientBalanceError");
841        assert_eq!(problem.status, 402);
842    }
843
844    #[test]
845    fn test_problem_details_with_challenge_id() {
846        let problem = PaymentErrorDetails::core("test-error")
847            .with_title("TestError")
848            .with_challenge_id("abc123");
849
850        assert_eq!(problem.challenge_id, Some("abc123".to_string()));
851    }
852
853    #[test]
854    fn test_problem_details_serialize() {
855        let problem = PaymentErrorDetails::core("verification-failed")
856            .with_title("VerificationFailedError")
857            .with_status(402)
858            .with_detail("Payment verification failed.")
859            .with_challenge_id("abc123");
860
861        let json = serde_json::to_string(&problem).unwrap();
862        assert!(json.contains("\"type\":"));
863        assert!(json.contains("verification-failed"));
864        assert!(json.contains("\"challengeId\":\"abc123\""));
865        // hint should be omitted when None
866        assert!(!json.contains("\"hint\""));
867    }
868
869    #[test]
870    fn test_problem_details_with_hint() {
871        let problem = PaymentErrorDetails::core("payment-required")
872            .with_title("PaymentRequiredError")
873            .with_status(402)
874            .with_detail("Payment is required.")
875            .with_hint("Use a wallet");
876
877        assert_eq!(problem.hint, Some("Use a wallet".to_string()));
878
879        let json = serde_json::to_string(&problem).unwrap();
880        assert!(json.contains("\"hint\":\"Use a wallet\""));
881    }
882
883    #[test]
884    fn test_payment_required_default_hint() {
885        let err = MppError::payment_required_default();
886        let problem = err.to_problem_details(None);
887        assert_eq!(problem.hint, Some(HINT_PAYMENT_REQUIRED.to_string()));
888    }
889
890    #[test]
891    fn test_malformed_credential_default_hint() {
892        let err = MppError::malformed_credential_default();
893        let problem = err.to_problem_details(None);
894        assert_eq!(problem.hint, Some(HINT_MALFORMED_CREDENTIAL.to_string()));
895    }
896
897    #[test]
898    fn test_method_unsupported_default_hint() {
899        let err = MppError::unsupported_method(&"lightning");
900        let problem = err.to_problem_details(None);
901        assert_eq!(problem.hint, Some(HINT_METHOD_UNSUPPORTED.to_string()));
902    }
903
904    #[test]
905    fn test_other_errors_no_hint() {
906        let err = MppError::verification_failed("bad proof");
907        let problem = err.to_problem_details(None);
908        assert_eq!(problem.hint, None);
909    }
910
911    #[test]
912    fn test_malformed_credential_error() {
913        let err = MppError::malformed_credential_default();
914        assert_eq!(err.to_string(), "Credential is malformed.");
915
916        let err = MppError::malformed_credential("invalid base64url");
917        assert_eq!(
918            err.to_string(),
919            "Credential is malformed: invalid base64url."
920        );
921
922        let problem = err.to_problem_details(Some("test-id"));
923        assert_eq!(
924            problem.problem_type,
925            "https://paymentauth.org/problems/malformed-credential"
926        );
927        assert_eq!(problem.title, "MalformedCredentialError");
928        assert_eq!(problem.challenge_id, Some("test-id".to_string()));
929    }
930
931    #[test]
932    fn test_invalid_challenge_error() {
933        let err = MppError::invalid_challenge_default();
934        assert_eq!(err.to_string(), "Challenge is invalid.");
935
936        let err = MppError::invalid_challenge_id("abc123");
937        assert_eq!(err.to_string(), "Challenge \"abc123\" is invalid.");
938
939        let err = MppError::invalid_challenge_reason("expired");
940        assert_eq!(err.to_string(), "Challenge is invalid: expired.");
941
942        let err = MppError::invalid_challenge("abc123", "already used");
943        assert_eq!(
944            err.to_string(),
945            "Challenge \"abc123\" is invalid: already used."
946        );
947
948        let problem = err.to_problem_details(None);
949        assert_eq!(
950            problem.problem_type,
951            "https://paymentauth.org/problems/invalid-challenge"
952        );
953        assert_eq!(problem.challenge_id, Some("abc123".to_string()));
954    }
955
956    #[test]
957    fn test_verification_failed_error() {
958        let err = MppError::verification_failed_default();
959        assert_eq!(err.to_string(), "Payment verification failed.");
960
961        let err = MppError::verification_failed("insufficient amount");
962        assert_eq!(
963            err.to_string(),
964            "Payment verification failed: insufficient amount."
965        );
966
967        let problem = err.to_problem_details(None);
968        assert_eq!(
969            problem.problem_type,
970            "https://paymentauth.org/problems/verification-failed"
971        );
972        assert_eq!(problem.title, "VerificationFailedError");
973    }
974
975    #[test]
976    fn test_payment_expired_error() {
977        let err = MppError::payment_expired_default();
978        assert_eq!(err.to_string(), "Payment has expired.");
979
980        let err = MppError::payment_expired("2025-01-15T12:00:00Z");
981        assert_eq!(err.to_string(), "Payment expired at 2025-01-15T12:00:00Z.");
982
983        let problem = err.to_problem_details(None);
984        assert_eq!(
985            problem.problem_type,
986            "https://paymentauth.org/problems/payment-expired"
987        );
988    }
989
990    #[test]
991    fn test_payment_required_error() {
992        let err = MppError::payment_required_default();
993        assert_eq!(err.to_string(), "Payment is required.");
994
995        let err = MppError::payment_required_realm("api.example.com");
996        assert_eq!(
997            err.to_string(),
998            "Payment is required for \"api.example.com\"."
999        );
1000
1001        let err = MppError::payment_required_description("Premium content access");
1002        assert_eq!(
1003            err.to_string(),
1004            "Payment is required (Premium content access)."
1005        );
1006
1007        let err = MppError::payment_required("api.example.com", "Premium access");
1008        assert_eq!(
1009            err.to_string(),
1010            "Payment is required for \"api.example.com\" (Premium access)."
1011        );
1012
1013        let problem = err.to_problem_details(Some("chal-id"));
1014        assert_eq!(
1015            problem.problem_type,
1016            "https://paymentauth.org/problems/payment-required"
1017        );
1018        assert_eq!(problem.challenge_id, Some("chal-id".to_string()));
1019    }
1020
1021    #[test]
1022    fn test_bad_request_error() {
1023        let err = MppError::bad_request_default();
1024        assert_eq!(err.to_string(), "Bad request.");
1025
1026        let err = MppError::bad_request("invalid parameters");
1027        assert_eq!(err.to_string(), "Bad request: invalid parameters.");
1028
1029        let problem = err.to_problem_details(None);
1030        assert_eq!(
1031            problem.problem_type,
1032            "https://paymentauth.org/problems/bad-request"
1033        );
1034        assert_eq!(problem.title, "BadRequestError");
1035        assert_eq!(problem.status, 400);
1036    }
1037
1038    #[test]
1039    fn test_unsupported_payment_method_problem_details() {
1040        let err = MppError::unsupported_method(&"lightning");
1041        let problem = err.to_problem_details(None);
1042        assert_eq!(
1043            problem.problem_type,
1044            "https://paymentauth.org/problems/method-unsupported"
1045        );
1046        assert_eq!(problem.title, "PaymentMethodUnsupportedError");
1047        assert_eq!(problem.status, 400);
1048    }
1049
1050    #[test]
1051    fn test_payment_action_required_error() {
1052        let err = MppError::payment_action_required_default();
1053        assert_eq!(err.to_string(), "Payment requires action.");
1054
1055        let err = MppError::payment_action_required("requires_action");
1056        assert_eq!(err.to_string(), "Payment requires action: requires_action.");
1057
1058        let problem = err.to_problem_details(None);
1059        assert_eq!(
1060            problem.problem_type,
1061            "https://paymentauth.org/problems/payment-action-required"
1062        );
1063        assert_eq!(problem.title, "PaymentActionRequiredError");
1064        assert_eq!(problem.status, 402);
1065    }
1066
1067    #[test]
1068    fn test_payment_insufficient_error() {
1069        let err = MppError::payment_insufficient_default();
1070        assert_eq!(err.to_string(), "Payment amount is insufficient.");
1071
1072        let err = MppError::payment_insufficient("expected 1000, received 500");
1073        assert_eq!(
1074            err.to_string(),
1075            "Payment insufficient: expected 1000, received 500."
1076        );
1077
1078        let problem = err.to_problem_details(None);
1079        assert_eq!(
1080            problem.problem_type,
1081            "https://paymentauth.org/problems/payment-insufficient"
1082        );
1083        assert_eq!(problem.title, "PaymentInsufficientError");
1084        assert_eq!(problem.status, 402);
1085    }
1086
1087    #[test]
1088    fn test_insufficient_balance_problem_details() {
1089        let err = MppError::InsufficientBalance(Some("requested 500, available 100".to_string()));
1090        assert_eq!(
1091            err.to_string(),
1092            "Insufficient balance: requested 500, available 100."
1093        );
1094        let problem = err.to_problem_details(None);
1095        assert_eq!(
1096            problem.problem_type,
1097            "https://paymentauth.org/problems/session/insufficient-balance"
1098        );
1099        assert_eq!(problem.title, "InsufficientBalanceError");
1100        assert_eq!(problem.status, 402);
1101
1102        let err = MppError::InsufficientBalance(None);
1103        assert_eq!(err.to_string(), "Insufficient balance.");
1104    }
1105
1106    #[test]
1107    fn test_invalid_signature_problem_details() {
1108        let err = MppError::InvalidSignature(Some("ECDSA recovery failed".to_string()));
1109        assert_eq!(err.to_string(), "Invalid signature: ECDSA recovery failed.");
1110        let problem = err.to_problem_details(None);
1111        assert_eq!(
1112            problem.problem_type,
1113            "https://paymentauth.org/problems/session/invalid-signature"
1114        );
1115        assert_eq!(problem.title, "InvalidSignatureError");
1116        assert_eq!(problem.status, 402);
1117
1118        let err = MppError::InvalidSignature(None);
1119        assert_eq!(err.to_string(), "Invalid signature.");
1120    }
1121
1122    #[test]
1123    fn test_signer_mismatch_problem_details() {
1124        let err = MppError::SignerMismatch(Some("expected 0x123, got 0x456".to_string()));
1125        assert_eq!(
1126            err.to_string(),
1127            "Signer mismatch: expected 0x123, got 0x456."
1128        );
1129        let problem = err.to_problem_details(None);
1130        assert_eq!(
1131            problem.problem_type,
1132            "https://paymentauth.org/problems/session/signer-mismatch"
1133        );
1134        assert_eq!(problem.title, "SignerMismatchError");
1135        assert_eq!(problem.status, 402);
1136
1137        let err = MppError::SignerMismatch(None);
1138        assert_eq!(
1139            err.to_string(),
1140            "Signer is not authorized for this channel."
1141        );
1142    }
1143
1144    #[test]
1145    fn test_amount_exceeds_deposit_problem_details() {
1146        let err = MppError::AmountExceedsDeposit(Some("voucher exceeds deposit".to_string()));
1147        assert_eq!(
1148            err.to_string(),
1149            "Amount exceeds deposit: voucher exceeds deposit."
1150        );
1151        let problem = err.to_problem_details(None);
1152        assert_eq!(
1153            problem.problem_type,
1154            "https://paymentauth.org/problems/session/amount-exceeds-deposit"
1155        );
1156        assert_eq!(problem.title, "AmountExceedsDepositError");
1157        assert_eq!(problem.status, 402);
1158
1159        let err = MppError::AmountExceedsDeposit(None);
1160        assert_eq!(err.to_string(), "Voucher amount exceeds channel deposit.");
1161    }
1162
1163    #[test]
1164    fn test_delta_too_small_problem_details() {
1165        let err = MppError::DeltaTooSmall(Some("increase below minimum".to_string()));
1166        assert_eq!(err.to_string(), "Delta too small: increase below minimum.");
1167        let problem = err.to_problem_details(None);
1168        assert_eq!(
1169            problem.problem_type,
1170            "https://paymentauth.org/problems/session/delta-too-small"
1171        );
1172        assert_eq!(problem.title, "DeltaTooSmallError");
1173        assert_eq!(problem.status, 402);
1174
1175        let err = MppError::DeltaTooSmall(None);
1176        assert_eq!(
1177            err.to_string(),
1178            "Amount increase below minimum voucher delta."
1179        );
1180    }
1181
1182    #[test]
1183    fn test_non_payment_error_problem_details() {
1184        let err = MppError::Http("connection refused".to_string());
1185        assert!(!err.is_payment_problem());
1186        let problem = err.to_problem_details(None);
1187        assert_eq!(
1188            problem.problem_type,
1189            "https://paymentauth.org/problems/internal-payment-error"
1190        );
1191        assert_eq!(problem.title, "InternalPaymentError");
1192        assert_eq!(problem.status, 500);
1193    }
1194
1195    #[test]
1196    fn test_internal_problem_detail_hides_error_text() {
1197        let message = "error sending request for url (https://rpc.example/v2/SECRETKEY)";
1198        for err in [
1199            MppError::Http(message.to_string()),
1200            MppError::Internal(message.to_string()),
1201        ] {
1202            assert!(err.to_string().contains(message));
1203            let problem = err.to_problem_details(Some("challenge-id"));
1204            assert_eq!(problem.detail, "An internal payment error occurred.");
1205            assert_eq!(problem.challenge_id.as_deref(), Some("challenge-id"));
1206        }
1207    }
1208
1209    #[test]
1210    fn test_channel_not_found_problem_details() {
1211        let err = MppError::ChannelNotFound(Some("no such channel".to_string()));
1212        assert_eq!(err.to_string(), "Channel not found: no such channel.");
1213        let problem = err.to_problem_details(None);
1214        assert_eq!(
1215            problem.problem_type,
1216            "https://paymentauth.org/problems/session/channel-not-found"
1217        );
1218        assert_eq!(problem.title, "ChannelNotFoundError");
1219        assert_eq!(problem.status, 410);
1220
1221        let err = MppError::ChannelNotFound(None);
1222        assert_eq!(err.to_string(), "No channel with this ID exists.");
1223    }
1224
1225    #[test]
1226    fn test_channel_closed_problem_details() {
1227        let err = MppError::ChannelClosed(Some("channel is finalized on-chain".to_string()));
1228        assert_eq!(
1229            err.to_string(),
1230            "Channel closed: channel is finalized on-chain."
1231        );
1232        let problem = err.to_problem_details(None);
1233        assert_eq!(
1234            problem.problem_type,
1235            "https://paymentauth.org/problems/session/channel-finalized"
1236        );
1237        assert_eq!(problem.title, "ChannelClosedError");
1238        assert_eq!(problem.status, 410);
1239
1240        let err = MppError::ChannelClosed(None);
1241        assert_eq!(err.to_string(), "Channel is closed.");
1242    }
1243
1244    #[test]
1245    fn test_invalid_payload_error() {
1246        let err = MppError::invalid_payload_default();
1247        assert_eq!(err.to_string(), "Credential payload is invalid.");
1248
1249        let err = MppError::invalid_payload("missing signature field");
1250        assert_eq!(
1251            err.to_string(),
1252            "Credential payload is invalid: missing signature field."
1253        );
1254
1255        let problem = err.to_problem_details(None);
1256        assert_eq!(
1257            problem.problem_type,
1258            "https://paymentauth.org/problems/invalid-payload"
1259        );
1260        assert_eq!(problem.title, "InvalidPayloadError");
1261    }
1262
1263    // --- Tempo error wrapping ---
1264
1265    #[cfg(all(feature = "client", feature = "tempo"))]
1266    #[test]
1267    fn test_tempo_error_wraps_through_from() {
1268        use crate::client::tempo::TempoClientError;
1269
1270        let tempo_err = TempoClientError::AccessKeyNotProvisioned;
1271        let mpp_err: MppError = tempo_err.into();
1272        assert!(matches!(mpp_err, MppError::Tempo(_)));
1273        assert_eq!(mpp_err.to_string(), "Access key not provisioned on wallet");
1274    }
1275}