Skip to main content

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