Skip to main content

mpay/
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 std::error::Error as StdError;
9use thiserror::Error;
10
11/// Result type alias for mpp operations.
12pub type Result<T> = std::result::Result<T, MppError>;
13
14// ==================== RFC 9457 Problem Details ====================
15
16/// Base URI for payment-related problem types.
17pub const PROBLEM_TYPE_BASE: &str = "https://paymentauth.org/problems";
18
19/// RFC 9457 Problem Details structure for payment errors.
20///
21/// This struct provides a standardized format for HTTP error responses,
22/// following [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html).
23///
24/// # Example
25///
26/// ```
27/// use mpay::error::PaymentErrorDetails;
28///
29/// let problem = PaymentErrorDetails::new("verification-failed")
30///     .with_title("VerificationFailedError")
31///     .with_status(402)
32///     .with_detail("Payment verification failed: insufficient amount.");
33///
34/// // Serialize to JSON for HTTP response body
35/// let json = serde_json::to_string(&problem).unwrap();
36/// ```
37#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
38pub struct PaymentErrorDetails {
39    /// A URI reference that identifies the problem type.
40    #[serde(rename = "type")]
41    pub problem_type: String,
42
43    /// A short, human-readable summary of the problem type.
44    pub title: String,
45
46    /// The HTTP status code for this problem.
47    pub status: u16,
48
49    /// A human-readable explanation specific to this occurrence.
50    pub detail: String,
51
52    /// The challenge ID associated with this error, if applicable.
53    #[serde(rename = "challengeId", skip_serializing_if = "Option::is_none")]
54    pub challenge_id: Option<String>,
55}
56
57impl PaymentErrorDetails {
58    /// Create a new PaymentErrorDetails with the given problem type suffix.
59    ///
60    /// The full type URI will be constructed as `{PROBLEM_TYPE_BASE}/{suffix}`.
61    pub fn new(type_suffix: impl Into<String>) -> Self {
62        let suffix = type_suffix.into();
63        Self {
64            problem_type: format!("{}/{}", PROBLEM_TYPE_BASE, suffix),
65            title: String::new(),
66            status: 402,
67            detail: String::new(),
68            challenge_id: None,
69        }
70    }
71
72    /// Set the title.
73    pub fn with_title(mut self, title: impl Into<String>) -> Self {
74        self.title = title.into();
75        self
76    }
77
78    /// Set the HTTP status code.
79    pub fn with_status(mut self, status: u16) -> Self {
80        self.status = status;
81        self
82    }
83
84    /// Set the detail message.
85    pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
86        self.detail = detail.into();
87        self
88    }
89
90    /// Set the associated challenge ID.
91    pub fn with_challenge_id(mut self, id: impl Into<String>) -> Self {
92        self.challenge_id = Some(id.into());
93        self
94    }
95}
96
97/// Trait for errors that can be converted to RFC 9457 Problem Details.
98///
99/// Implement this trait to enable automatic conversion of payment errors
100/// to standardized HTTP error responses.
101///
102/// # Example
103///
104/// ```
105/// use mpay::error::{PaymentError, PaymentErrorDetails};
106///
107/// struct MyError {
108///     reason: String,
109/// }
110///
111/// impl PaymentError for MyError {
112///     fn to_problem_details(&self, challenge_id: Option<&str>) -> PaymentErrorDetails {
113///         PaymentErrorDetails::new("my-error")
114///             .with_title("MyError")
115///             .with_status(402)
116///             .with_detail(&self.reason)
117///     }
118/// }
119/// ```
120pub trait PaymentError {
121    /// Convert this error to RFC 9457 Problem Details format.
122    ///
123    /// # Arguments
124    ///
125    /// * `challenge_id` - Optional challenge ID to include in the response
126    fn to_problem_details(&self, challenge_id: Option<&str>) -> PaymentErrorDetails;
127}
128
129/// Context for signing errors
130#[derive(Debug, Clone)]
131pub struct SigningContext {
132    pub network: Option<String>,
133    pub address: Option<String>,
134    pub operation: &'static str,
135}
136
137impl Default for SigningContext {
138    fn default() -> Self {
139        Self {
140            network: None,
141            address: None,
142            operation: "sign",
143        }
144    }
145}
146
147impl std::fmt::Display for SigningContext {
148    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149        write!(f, "operation: {}", self.operation)?;
150        if let Some(ref network) = self.network {
151            write!(f, ", network: {}", network)?;
152        }
153        if let Some(ref address) = self.address {
154            write!(f, ", address: {}", address)?;
155        }
156        Ok(())
157    }
158}
159
160#[derive(Error, Debug)]
161pub enum MppError {
162    #[error("Payment provider not found for network: {0}")]
163    ProviderNotFound(String),
164
165    /// No payment methods are configured
166    #[error("No payment methods configured")]
167    NoPaymentMethods,
168
169    /// No compatible payment method for the server's requirements
170    #[error("No compatible payment method found. Available networks: {networks:?}")]
171    NoCompatibleMethod { networks: Vec<String> },
172
173    /// Required amount exceeds user's maximum allowed
174    #[error("Required amount ({required}) exceeds maximum allowed ({max})")]
175    AmountExceedsMax { required: u128, max: u128 },
176
177    /// Invalid amount format
178    #[error("Invalid amount: {0}")]
179    InvalidAmount(String),
180
181    /// Missing required field
182    #[error("Missing payment requirement: {0}")]
183    MissingRequirement(String),
184
185    /// Configuration file or value is missing
186    #[error("Configuration missing: {0}")]
187    ConfigMissing(String),
188
189    /// Configuration is invalid
190    #[error("Invalid configuration: {0}")]
191    InvalidConfig(String),
192
193    /// Invalid private key format
194    #[error("Invalid private key: {0}")]
195    InvalidKey(String),
196
197    /// Failed to determine config directory
198    #[error("Failed to determine config directory")]
199    NoConfigDir,
200
201    /// Unknown network identifier
202    #[error("Unknown network: {0}")]
203    UnknownNetwork(String),
204
205    /// Token not configured for network
206    #[error("Token configuration not found for asset {asset} on network {network}")]
207    TokenConfigNotFound { asset: String, network: String },
208
209    /// Unsupported token type
210    #[error("Unsupported token: {0}")]
211    UnsupportedToken(String),
212
213    /// Balance query failed
214    #[error("Balance query failed: {0}")]
215    BalanceQuery(String),
216
217    // ==================== HTTP Errors ====================
218    /// HTTP request/response error
219    #[error("HTTP error: {0}")]
220    Http(String),
221
222    /// Chain ID mismatch between challenge and provider
223    #[error("Chain ID mismatch: challenge requires {expected}, provider connected to {got}")]
224    ChainIdMismatch { expected: u64, got: u64 },
225
226    /// Transaction was confirmed but reverted
227    #[error("Transaction reverted: {0}")]
228    TransactionReverted(String),
229
230    /// Failed to format credential for Authorization header
231    #[error("Failed to format credential: {0}")]
232    CredentialFormat(String),
233
234    /// Unsupported HTTP method
235    #[error("Unsupported HTTP method: {0}")]
236    UnsupportedHttpMethod(String),
237
238    /// Signing error with context and source chain
239    #[error("signing failed ({context})")]
240    Signing {
241        #[source]
242        source: Box<dyn StdError + Send + Sync>,
243        context: SigningContext,
244    },
245
246    /// Address parsing error
247    #[error("Invalid address: {0}")]
248    InvalidAddress(String),
249
250    /// JSON serialization/deserialization error
251    #[error("JSON error: {0}")]
252    Json(#[from] serde_json::Error),
253
254    /// Hex decoding error
255    #[cfg(feature = "utils")]
256    #[error("Hex decoding error: {0}")]
257    HexDecode(#[from] hex::FromHexError),
258
259    /// Base64 decoding error
260    #[cfg(feature = "utils")]
261    #[error("Base64 decoding error: {0}")]
262    Base64Decode(#[from] base64::DecodeError),
263
264    // ==================== Web Payment Auth Errors ====================
265    /// Unsupported payment method
266    #[error("Unsupported payment method: {0}")]
267    UnsupportedPaymentMethod(String),
268
269    /// Unsupported payment intent
270    #[error("Unsupported payment intent: {0}")]
271    UnsupportedPaymentIntent(String),
272
273    /// Missing required header
274    #[error("Missing required header: {0}")]
275    MissingHeader(String),
276
277    /// Invalid base64url encoding
278    #[error("Invalid base64url: {0}")]
279    InvalidBase64Url(String),
280
281    /// Invalid DID format
282    #[error("Invalid DID: {0}")]
283    InvalidDid(String),
284
285    // ==================== RFC 9457 Payment Problems ====================
286    // These variants can be converted to RFC 9457 Problem Details format.
287    /// Credential is malformed (invalid base64url, bad JSON structure).
288    #[error("{}", format_malformed_credential(.0))]
289    MalformedCredential(Option<String>),
290
291    /// Challenge ID is unknown, expired, or already used.
292    #[error("{}", format_invalid_challenge(.id, .reason))]
293    InvalidChallenge {
294        id: Option<String>,
295        reason: Option<String>,
296    },
297
298    /// Payment proof is invalid or verification failed.
299    #[error("{}", format_verification_failed(.0))]
300    VerificationFailed(Option<String>),
301
302    /// Payment has expired.
303    #[error("{}", format_payment_expired(.0))]
304    PaymentExpired(Option<String>),
305
306    /// No credential was provided but payment is required.
307    #[error("{}", format_payment_required(.realm, .description))]
308    PaymentRequired {
309        realm: Option<String>,
310        description: Option<String>,
311    },
312
313    /// Credential payload does not match the expected schema.
314    #[error("{}", format_invalid_payload(.0))]
315    InvalidPayload(Option<String>),
316
317    // ==================== External Library Errors ====================
318    /// IO error
319    #[error("IO error: {0}")]
320    Io(#[from] std::io::Error),
321
322    /// Invalid UTF-8 in response
323    #[error("Invalid UTF-8 in response body")]
324    InvalidUtf8(#[from] std::string::FromUtf8Error),
325
326    /// System time error
327    #[error("System time error: {0}")]
328    SystemTime(#[from] std::time::SystemTimeError),
329}
330
331// ==================== RFC 9457 Format Helpers ====================
332
333fn format_malformed_credential(reason: &Option<String>) -> String {
334    match reason {
335        Some(r) => format!("Credential is malformed: {}.", r),
336        None => "Credential is malformed.".to_string(),
337    }
338}
339
340fn format_invalid_challenge(id: &Option<String>, reason: &Option<String>) -> String {
341    let id_part = id
342        .as_ref()
343        .map(|id| format!(" \"{}\"", id))
344        .unwrap_or_default();
345    let reason_part = reason
346        .as_ref()
347        .map(|r| format!(": {}", r))
348        .unwrap_or_default();
349    format!("Challenge{} is invalid{}.", id_part, reason_part)
350}
351
352fn format_verification_failed(reason: &Option<String>) -> String {
353    match reason {
354        Some(r) => format!("Payment verification failed: {}.", r),
355        None => "Payment verification failed.".to_string(),
356    }
357}
358
359fn format_payment_expired(expires: &Option<String>) -> String {
360    match expires {
361        Some(e) => format!("Payment expired at {}.", e),
362        None => "Payment has expired.".to_string(),
363    }
364}
365
366fn format_payment_required(realm: &Option<String>, description: &Option<String>) -> String {
367    let mut s = "Payment is required".to_string();
368    if let Some(r) = realm {
369        s.push_str(&format!(" for \"{}\"", r));
370    }
371    if let Some(d) = description {
372        s.push_str(&format!(" ({})", d));
373    }
374    s.push('.');
375    s
376}
377
378fn format_invalid_payload(reason: &Option<String>) -> String {
379    match reason {
380        Some(r) => format!("Credential payload is invalid: {}.", r),
381        None => "Credential payload is invalid.".to_string(),
382    }
383}
384
385impl MppError {
386    /// Create a signing error with context and source chain
387    pub fn signing_with_context(
388        source: impl StdError + Send + Sync + 'static,
389        context: SigningContext,
390    ) -> Self {
391        Self::Signing {
392            source: Box::new(source),
393            context,
394        }
395    }
396
397    /// Add network context to an existing error
398    pub fn with_network(self, network: impl Into<String>) -> Self {
399        match self {
400            Self::Signing {
401                source,
402                mut context,
403            } => {
404                context.network = Some(network.into());
405                Self::Signing { source, context }
406            }
407            other => other,
408        }
409    }
410
411    /// Create an invalid address error
412    pub fn invalid_address(msg: impl Into<String>) -> Self {
413        Self::InvalidAddress(msg.into())
414    }
415
416    /// Create a config missing error
417    pub fn config_missing(msg: impl Into<String>) -> Self {
418        Self::ConfigMissing(msg.into())
419    }
420
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    /// Returns the RFC 9457 problem type suffix if this is a payment problem.
533    pub fn problem_type_suffix(&self) -> Option<&'static str> {
534        match self {
535            Self::MalformedCredential(_) => Some("malformed-credential"),
536            Self::InvalidChallenge { .. } => Some("invalid-challenge"),
537            Self::VerificationFailed(_) => Some("verification-failed"),
538            Self::PaymentExpired(_) => Some("payment-expired"),
539            Self::PaymentRequired { .. } => Some("payment-required"),
540            Self::InvalidPayload(_) => Some("invalid-payload"),
541            _ => None,
542        }
543    }
544
545    /// Returns true if this error is an RFC 9457 payment problem.
546    pub fn is_payment_problem(&self) -> bool {
547        self.problem_type_suffix().is_some()
548    }
549}
550
551impl PaymentError for MppError {
552    fn to_problem_details(&self, challenge_id: Option<&str>) -> PaymentErrorDetails {
553        let (suffix, title) = match self {
554            Self::MalformedCredential(_) => ("malformed-credential", "MalformedCredentialError"),
555            Self::InvalidChallenge { .. } => ("invalid-challenge", "InvalidChallengeError"),
556            Self::VerificationFailed(_) => ("verification-failed", "VerificationFailedError"),
557            Self::PaymentExpired(_) => ("payment-expired", "PaymentExpiredError"),
558            Self::PaymentRequired { .. } => ("payment-required", "PaymentRequiredError"),
559            Self::InvalidPayload(_) => ("invalid-payload", "InvalidPayloadError"),
560            // Non-payment-problem errors get a generic problem type
561            _ => ("internal-error", "InternalError"),
562        };
563
564        let mut problem = PaymentErrorDetails::new(suffix)
565            .with_title(title)
566            .with_status(402)
567            .with_detail(self.to_string());
568
569        // Use embedded challenge ID from InvalidChallenge, or the provided one
570        let embedded_id = match self {
571            Self::InvalidChallenge { id, .. } => id.as_deref(),
572            _ => None,
573        };
574        if let Some(id) = challenge_id.or(embedded_id) {
575            problem = problem.with_challenge_id(id);
576        }
577        problem
578    }
579}
580
581/// Extension trait for adding context to Results
582pub trait ResultExt<T> {
583    /// Add signing context to an error
584    fn with_signing_context(self, context: SigningContext) -> Result<T>;
585
586    /// Add network context to an error
587    fn with_network(self, network: &str) -> Result<T>;
588}
589
590impl<T, E: StdError + Send + Sync + 'static> ResultExt<T> for std::result::Result<T, E> {
591    fn with_signing_context(self, context: SigningContext) -> Result<T> {
592        self.map_err(|e| MppError::signing_with_context(e, context))
593    }
594
595    fn with_network(self, network: &str) -> Result<T> {
596        self.map_err(|e| {
597            MppError::signing_with_context(
598                e,
599                SigningContext {
600                    network: Some(network.to_string()),
601                    address: None,
602                    operation: "sign",
603                },
604            )
605        })
606    }
607}
608
609#[cfg(test)]
610mod tests {
611    use super::*;
612
613    #[test]
614    fn test_no_compatible_method_display() {
615        let err = MppError::NoCompatibleMethod {
616            networks: vec!["ethereum".to_string(), "tempo".to_string()],
617        };
618        let display = err.to_string();
619        assert!(display.contains("No compatible payment method"));
620        assert!(display.contains("ethereum"));
621        assert!(display.contains("tempo"));
622    }
623
624    #[test]
625    fn test_amount_exceeds_max_display() {
626        let err = MppError::AmountExceedsMax {
627            required: 1000,
628            max: 500,
629        };
630        let display = err.to_string();
631        assert!(display.contains("Required amount (1000) exceeds maximum allowed (500)"));
632    }
633
634    #[test]
635    fn test_invalid_amount_display() {
636        let err = MppError::InvalidAmount("not a number".to_string());
637        assert_eq!(err.to_string(), "Invalid amount: not a number");
638    }
639
640    #[test]
641    fn test_missing_requirement_display() {
642        let err = MppError::MissingRequirement("network".to_string());
643        assert_eq!(err.to_string(), "Missing payment requirement: network");
644    }
645
646    #[test]
647    fn test_config_missing_display() {
648        let err = MppError::ConfigMissing("wallet not configured".to_string());
649        assert_eq!(
650            err.to_string(),
651            "Configuration missing: wallet not configured"
652        );
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_invalid_key_display() {
663        let err = MppError::InvalidKey("wrong format".to_string());
664        assert_eq!(err.to_string(), "Invalid private key: wrong format");
665    }
666
667    #[test]
668    fn test_no_config_dir_display() {
669        let err = MppError::NoConfigDir;
670        assert_eq!(err.to_string(), "Failed to determine config directory");
671    }
672
673    #[test]
674    fn test_unknown_network_display() {
675        let err = MppError::UnknownNetwork("custom-chain".to_string());
676        assert_eq!(err.to_string(), "Unknown network: custom-chain");
677    }
678
679    #[test]
680    fn test_token_config_not_found_display() {
681        let err = MppError::TokenConfigNotFound {
682            asset: "USDC".to_string(),
683            network: "ethereum".to_string(),
684        };
685        let display = err.to_string();
686        assert!(
687            display.contains("Token configuration not found for asset USDC on network ethereum")
688        );
689    }
690
691    #[test]
692    fn test_unsupported_token_display() {
693        let err = MppError::UnsupportedToken("UNKNOWN".to_string());
694        assert_eq!(err.to_string(), "Unsupported token: UNKNOWN");
695    }
696
697    #[test]
698    fn test_balance_query_display() {
699        let err = MppError::BalanceQuery("RPC timeout".to_string());
700        assert_eq!(err.to_string(), "Balance query failed: RPC timeout");
701    }
702
703    #[test]
704    fn test_http_display() {
705        let err = MppError::Http("404 Not Found".to_string());
706        assert_eq!(err.to_string(), "HTTP error: 404 Not Found");
707    }
708
709    #[test]
710    fn test_unsupported_http_method_display() {
711        let err = MppError::UnsupportedHttpMethod("TRACE".to_string());
712        assert_eq!(err.to_string(), "Unsupported HTTP method: TRACE");
713    }
714
715    #[test]
716    fn test_invalid_address_display() {
717        let err = MppError::InvalidAddress("Not a valid address".to_string());
718        assert_eq!(err.to_string(), "Invalid address: Not a valid address");
719    }
720
721    #[test]
722    fn test_unsupported_payment_method_display() {
723        let err = MppError::UnsupportedPaymentMethod("bitcoin".to_string());
724        assert_eq!(err.to_string(), "Unsupported payment method: bitcoin");
725    }
726
727    #[test]
728    fn test_unsupported_payment_intent_display() {
729        let err = MppError::UnsupportedPaymentIntent("subscription".to_string());
730        assert_eq!(err.to_string(), "Unsupported payment intent: subscription");
731    }
732
733    #[test]
734    fn test_invalid_challenge_display() {
735        let err = MppError::invalid_challenge_reason("Malformed challenge");
736        assert_eq!(
737            err.to_string(),
738            "Challenge is invalid: Malformed challenge."
739        );
740    }
741
742    #[test]
743    fn test_missing_header_display() {
744        let err = MppError::MissingHeader("WWW-Authenticate".to_string());
745        assert_eq!(err.to_string(), "Missing required header: WWW-Authenticate");
746    }
747
748    #[test]
749    fn test_invalid_base64_url_display() {
750        let err = MppError::InvalidBase64Url("Invalid padding".to_string());
751        assert_eq!(err.to_string(), "Invalid base64url: Invalid padding");
752    }
753
754    #[test]
755    fn test_challenge_expired_display() {
756        let err = MppError::payment_expired("2025-01-15T12:00:00Z");
757        assert_eq!(err.to_string(), "Payment expired at 2025-01-15T12:00:00Z.");
758    }
759
760    #[test]
761    fn test_invalid_did_display() {
762        let err = MppError::InvalidDid("Not a valid DID".to_string());
763        assert_eq!(err.to_string(), "Invalid DID: Not a valid DID");
764    }
765
766    #[test]
767    fn test_signing_with_context() {
768        use std::io::{Error as IoError, ErrorKind};
769        let source = IoError::new(ErrorKind::Other, "underlying error");
770        let ctx = SigningContext {
771            network: Some("tempo".to_string()),
772            address: Some("0x123".to_string()),
773            operation: "sign_transaction",
774        };
775        let err = MppError::signing_with_context(source, ctx);
776        let display = err.to_string();
777        assert!(display.contains("signing failed"));
778        assert!(display.contains("sign_transaction"));
779        assert!(display.contains("tempo"));
780        assert!(display.contains("0x123"));
781    }
782
783    #[test]
784    fn test_signing_context_display() {
785        let ctx = SigningContext {
786            network: Some("ethereum".to_string()),
787            address: Some("0xabc".to_string()),
788            operation: "get_nonce",
789        };
790        let display = ctx.to_string();
791        assert!(display.contains("operation: get_nonce"));
792        assert!(display.contains("network: ethereum"));
793        assert!(display.contains("address: 0xabc"));
794    }
795
796    #[test]
797    fn test_signing_context_default() {
798        let ctx = SigningContext::default();
799        assert_eq!(ctx.operation, "sign");
800        assert!(ctx.network.is_none());
801        assert!(ctx.address.is_none());
802    }
803
804    #[test]
805    fn test_result_ext_with_signing_context() {
806        use std::io::{Error as IoError, ErrorKind};
807        let result: std::result::Result<(), IoError> = Err(IoError::new(ErrorKind::Other, "test"));
808        let ctx = SigningContext {
809            network: Some("tempo".to_string()),
810            address: None,
811            operation: "test_op",
812        };
813        let mpp_result = result.with_signing_context(ctx);
814        assert!(mpp_result.is_err());
815        let err = mpp_result.unwrap_err();
816        assert!(err.to_string().contains("signing failed"));
817    }
818
819    #[test]
820    fn test_result_ext_with_network() {
821        use std::io::{Error as IoError, ErrorKind};
822        let result: std::result::Result<(), IoError> = Err(IoError::new(ErrorKind::Other, "test"));
823        let mpp_result = result.with_network("base-sepolia");
824        assert!(mpp_result.is_err());
825        let err = mpp_result.unwrap_err();
826        assert!(err.to_string().contains("base-sepolia"));
827    }
828
829    #[test]
830    fn test_with_network_on_signing_error() {
831        use std::io::{Error as IoError, ErrorKind};
832        let source = IoError::new(ErrorKind::Other, "test");
833        let err = MppError::signing_with_context(source, SigningContext::default());
834        let err_with_network = err.with_network("optimism");
835        assert!(err_with_network.to_string().contains("optimism"));
836    }
837
838    #[test]
839    fn test_invalid_address_constructor() {
840        let err = MppError::invalid_address("test address");
841        assert!(matches!(err, MppError::InvalidAddress(_)));
842        assert_eq!(err.to_string(), "Invalid address: test address");
843    }
844
845    #[test]
846    fn test_config_missing_constructor() {
847        let err = MppError::config_missing("test config");
848        assert!(matches!(err, MppError::ConfigMissing(_)));
849        assert_eq!(err.to_string(), "Configuration missing: test config");
850    }
851
852    #[test]
853    fn test_unsupported_method_constructor() {
854        let err = MppError::unsupported_method(&"bitcoin");
855        assert!(matches!(err, MppError::UnsupportedPaymentMethod(_)));
856        assert!(err.to_string().contains("bitcoin"));
857        assert!(err.to_string().contains("not supported"));
858    }
859
860    // ==================== RFC 9457 Problem Details Tests ====================
861
862    #[test]
863    fn test_problem_details_new() {
864        let problem = PaymentErrorDetails::new("test-error")
865            .with_title("TestError")
866            .with_status(400)
867            .with_detail("Something went wrong");
868
869        assert_eq!(
870            problem.problem_type,
871            "https://paymentauth.org/problems/test-error"
872        );
873        assert_eq!(problem.title, "TestError");
874        assert_eq!(problem.status, 400);
875        assert_eq!(problem.detail, "Something went wrong");
876        assert!(problem.challenge_id.is_none());
877    }
878
879    #[test]
880    fn test_problem_details_with_challenge_id() {
881        let problem = PaymentErrorDetails::new("test-error")
882            .with_title("TestError")
883            .with_challenge_id("abc123");
884
885        assert_eq!(problem.challenge_id, Some("abc123".to_string()));
886    }
887
888    #[test]
889    fn test_problem_details_serialize() {
890        let problem = PaymentErrorDetails::new("verification-failed")
891            .with_title("VerificationFailedError")
892            .with_status(402)
893            .with_detail("Payment verification failed.")
894            .with_challenge_id("abc123");
895
896        let json = serde_json::to_string(&problem).unwrap();
897        assert!(json.contains("\"type\":"));
898        assert!(json.contains("verification-failed"));
899        assert!(json.contains("\"challengeId\":\"abc123\""));
900    }
901
902    #[test]
903    fn test_malformed_credential_error() {
904        let err = MppError::malformed_credential_default();
905        assert_eq!(err.to_string(), "Credential is malformed.");
906
907        let err = MppError::malformed_credential("invalid base64url");
908        assert_eq!(
909            err.to_string(),
910            "Credential is malformed: invalid base64url."
911        );
912
913        let problem = err.to_problem_details(Some("test-id"));
914        assert!(problem.problem_type.contains("malformed-credential"));
915        assert_eq!(problem.title, "MalformedCredentialError");
916        assert_eq!(problem.challenge_id, Some("test-id".to_string()));
917    }
918
919    #[test]
920    fn test_invalid_challenge_error() {
921        let err = MppError::invalid_challenge_default();
922        assert_eq!(err.to_string(), "Challenge is invalid.");
923
924        let err = MppError::invalid_challenge_id("abc123");
925        assert_eq!(err.to_string(), "Challenge \"abc123\" is invalid.");
926
927        let err = MppError::invalid_challenge_reason("expired");
928        assert_eq!(err.to_string(), "Challenge is invalid: expired.");
929
930        let err = MppError::invalid_challenge("abc123", "already used");
931        assert_eq!(
932            err.to_string(),
933            "Challenge \"abc123\" is invalid: already used."
934        );
935
936        let problem = err.to_problem_details(None);
937        assert!(problem.problem_type.contains("invalid-challenge"));
938        assert_eq!(problem.challenge_id, Some("abc123".to_string()));
939    }
940
941    #[test]
942    fn test_verification_failed_error() {
943        let err = MppError::verification_failed_default();
944        assert_eq!(err.to_string(), "Payment verification failed.");
945
946        let err = MppError::verification_failed("insufficient amount");
947        assert_eq!(
948            err.to_string(),
949            "Payment verification failed: insufficient amount."
950        );
951
952        let problem = err.to_problem_details(None);
953        assert!(problem.problem_type.contains("verification-failed"));
954        assert_eq!(problem.title, "VerificationFailedError");
955    }
956
957    #[test]
958    fn test_payment_expired_error() {
959        let err = MppError::payment_expired_default();
960        assert_eq!(err.to_string(), "Payment has expired.");
961
962        let err = MppError::payment_expired("2025-01-15T12:00:00Z");
963        assert_eq!(err.to_string(), "Payment expired at 2025-01-15T12:00:00Z.");
964
965        let problem = err.to_problem_details(None);
966        assert!(problem.problem_type.contains("payment-expired"));
967    }
968
969    #[test]
970    fn test_payment_required_error() {
971        let err = MppError::payment_required_default();
972        assert_eq!(err.to_string(), "Payment is required.");
973
974        let err = MppError::payment_required_realm("api.example.com");
975        assert_eq!(
976            err.to_string(),
977            "Payment is required for \"api.example.com\"."
978        );
979
980        let err = MppError::payment_required_description("Premium content access");
981        assert_eq!(
982            err.to_string(),
983            "Payment is required (Premium content access)."
984        );
985
986        let err = MppError::payment_required("api.example.com", "Premium access");
987        assert_eq!(
988            err.to_string(),
989            "Payment is required for \"api.example.com\" (Premium access)."
990        );
991
992        let problem = err.to_problem_details(Some("chal-id"));
993        assert!(problem.problem_type.contains("payment-required"));
994        assert_eq!(problem.challenge_id, Some("chal-id".to_string()));
995    }
996
997    #[test]
998    fn test_invalid_payload_error() {
999        let err = MppError::invalid_payload_default();
1000        assert_eq!(err.to_string(), "Credential payload is invalid.");
1001
1002        let err = MppError::invalid_payload("missing signature field");
1003        assert_eq!(
1004            err.to_string(),
1005            "Credential payload is invalid: missing signature field."
1006        );
1007
1008        let problem = err.to_problem_details(None);
1009        assert!(problem.problem_type.contains("invalid-payload"));
1010        assert_eq!(problem.title, "InvalidPayloadError");
1011    }
1012}