1use thiserror::Error;
9
10pub type Result<T> = std::result::Result<T, MppError>;
12
13pub const CORE_PROBLEM_TYPE_BASE: &str = "https://paymentauth.org/problems";
17
18pub const SESSION_PROBLEM_TYPE_BASE: &str = "https://paymentauth.org/problems/session";
20
21#[deprecated(since = "0.5.0", note = "renamed to SESSION_PROBLEM_TYPE_BASE")]
23pub const STREAM_PROBLEM_TYPE_BASE: &str = SESSION_PROBLEM_TYPE_BASE;
24
25pub(crate) const INTERNAL_PROBLEM_TYPE_SUFFIX: &str = "internal-payment-error";
27
28const INTERNAL_PROBLEM_DETAIL: &str = "An internal payment error occurred.";
31
32pub 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
35pub 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
38pub const HINT_METHOD_UNSUPPORTED: &str = HINT_PAYMENT_REQUIRED;
40
41#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
60pub struct PaymentErrorDetails {
61 #[serde(rename = "type")]
63 pub problem_type: String,
64
65 pub title: String,
67
68 pub status: u16,
70
71 pub detail: String,
73
74 #[serde(rename = "challengeId", skip_serializing_if = "Option::is_none")]
76 pub challenge_id: Option<String>,
77
78 #[serde(skip_serializing_if = "Option::is_none")]
81 pub hint: Option<String>,
82}
83
84impl PaymentErrorDetails {
85 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 pub fn core(suffix: impl std::fmt::Display) -> Self {
101 Self::new(format!("{}/{}", CORE_PROBLEM_TYPE_BASE, suffix))
102 }
103
104 pub fn session(suffix: impl std::fmt::Display) -> Self {
108 Self::new(format!("{}/{}", SESSION_PROBLEM_TYPE_BASE, suffix))
109 }
110
111 #[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 pub fn with_title(mut self, title: impl Into<String>) -> Self {
119 self.title = title.into();
120 self
121 }
122
123 pub fn with_status(mut self, status: u16) -> Self {
125 self.status = status;
126 self
127 }
128
129 pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
131 self.detail = detail.into();
132 self
133 }
134
135 pub fn with_challenge_id(mut self, id: impl Into<String>) -> Self {
137 self.challenge_id = Some(id.into());
138 self
139 }
140
141 pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
143 self.hint = Some(hint.into());
144 self
145 }
146}
147
148pub trait PaymentError {
172 fn to_problem_details(&self, challenge_id: Option<&str>) -> PaymentErrorDetails;
178}
179
180#[derive(Error, Debug)]
181pub enum MppError {
182 #[error("Required amount ({required}) exceeds maximum allowed ({max})")]
184 AmountExceedsMax { required: u128, max: u128 },
185
186 #[error("Invalid amount: {0}")]
188 InvalidAmount(String),
189
190 #[error("Invalid configuration: {0}")]
192 InvalidConfig(String),
193
194 #[error("HTTP error: {0}")]
197 Http(String),
198
199 #[error("Chain ID mismatch: expected {expected}, got {got}")]
201 ChainIdMismatch { expected: u64, got: u64 },
202
203 #[error("JSON error: {0}")]
205 Json(#[from] serde_json::Error),
206
207 #[cfg(feature = "utils")]
209 #[error("Hex decoding error: {0}")]
210 HexDecode(#[from] hex::FromHexError),
211
212 #[cfg(feature = "utils")]
214 #[error("Base64 decoding error: {0}")]
215 Base64Decode(#[from] base64::DecodeError),
216
217 #[error("Unsupported payment method: {0}")]
220 UnsupportedPaymentMethod(String),
221
222 #[error("Missing required header: {0}")]
224 MissingHeader(String),
225
226 #[error("Invalid base64url: {0}")]
228 InvalidBase64Url(String),
229
230 #[error("Receipt is invalid: {0}.")]
232 InvalidReceipt(String),
233
234 #[error("Internal payment error: {0}")]
240 Internal(String),
241
242 #[error("{}", format_malformed_credential(.0))]
246 MalformedCredential(Option<String>),
247
248 #[error("{}", format_invalid_challenge(.id, .reason))]
250 InvalidChallenge {
251 id: Option<String>,
252 reason: Option<String>,
253 },
254
255 #[error("{}", format_verification_failed(.0))]
257 VerificationFailed(Option<String>),
258
259 #[error("{}", format_payment_expired(.0))]
261 PaymentExpired(Option<String>),
262
263 #[error("{}", format_payment_required(.realm, .description))]
265 PaymentRequired {
266 realm: Option<String>,
267 description: Option<String>,
268 },
269
270 #[error("{}", format_invalid_payload(.0))]
272 InvalidPayload(Option<String>),
273
274 #[error("{}", format_bad_request(.0))]
276 BadRequest(Option<String>),
277
278 #[error("{}", format_payment_action_required(.0))]
280 PaymentActionRequired(Option<String>),
281
282 #[error("{}", format_payment_insufficient(.0))]
284 PaymentInsufficient(Option<String>),
285
286 #[error("{}", format_insufficient_balance(.0))]
289 InsufficientBalance(Option<String>),
290
291 #[error("{}", format_invalid_signature(.0))]
293 InvalidSignature(Option<String>),
294
295 #[error("{}", format_signer_mismatch(.0))]
297 SignerMismatch(Option<String>),
298
299 #[error("{}", format_amount_exceeds_deposit(.0))]
301 AmountExceedsDeposit(Option<String>),
302
303 #[error("{}", format_delta_too_small(.0))]
305 DeltaTooSmall(Option<String>),
306
307 #[error("{}", format_channel_not_found(.0))]
309 ChannelNotFound(Option<String>),
310
311 #[error("{}", format_channel_closed(.0))]
313 ChannelClosed(Option<String>),
314
315 #[cfg(all(feature = "client", feature = "tempo"))]
320 #[error("{0}")]
321 Tempo(#[from] crate::client::tempo::TempoClientError),
322
323 #[error("IO error: {0}")]
326 Io(#[from] std::io::Error),
327
328 #[error("Invalid UTF-8 in response body")]
330 InvalidUtf8(#[from] std::string::FromUtf8Error),
331
332 #[error("System time error: {0}")]
334 SystemTime(#[from] std::time::SystemTimeError),
335}
336
337pub(crate) trait ResultExt<T> {
341 fn mpp_http(self, context: &str) -> std::result::Result<T, MppError>;
343
344 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
358fn 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 pub fn unsupported_method(method: &impl std::fmt::Display) -> Self {
485 Self::UnsupportedPaymentMethod(format!("Payment method '{}' is not supported", method))
486 }
487
488 pub fn malformed_credential(reason: impl Into<String>) -> Self {
492 Self::MalformedCredential(Some(reason.into()))
493 }
494
495 pub fn malformed_credential_default() -> Self {
497 Self::MalformedCredential(None)
498 }
499
500 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 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 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 pub fn invalid_challenge_default() -> Self {
526 Self::InvalidChallenge {
527 id: None,
528 reason: None,
529 }
530 }
531
532 pub fn verification_failed(reason: impl Into<String>) -> Self {
534 Self::VerificationFailed(Some(reason.into()))
535 }
536
537 pub fn verification_failed_default() -> Self {
539 Self::VerificationFailed(None)
540 }
541
542 pub fn payment_expired(expires: impl Into<String>) -> Self {
544 Self::PaymentExpired(Some(expires.into()))
545 }
546
547 pub fn payment_expired_default() -> Self {
549 Self::PaymentExpired(None)
550 }
551
552 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 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 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 pub fn payment_required_default() -> Self {
578 Self::PaymentRequired {
579 realm: None,
580 description: None,
581 }
582 }
583
584 pub fn invalid_payload(reason: impl Into<String>) -> Self {
586 Self::InvalidPayload(Some(reason.into()))
587 }
588
589 pub fn invalid_payload_default() -> Self {
591 Self::InvalidPayload(None)
592 }
593
594 pub fn bad_request(reason: impl Into<String>) -> Self {
596 Self::BadRequest(Some(reason.into()))
597 }
598
599 pub fn bad_request_default() -> Self {
601 Self::BadRequest(None)
602 }
603
604 pub fn payment_action_required(reason: impl Into<String>) -> Self {
606 Self::PaymentActionRequired(Some(reason.into()))
607 }
608
609 pub fn payment_action_required_default() -> Self {
611 Self::PaymentActionRequired(None)
612 }
613
614 pub fn payment_insufficient(reason: impl Into<String>) -> Self {
616 Self::PaymentInsufficient(Some(reason.into()))
617 }
618
619 pub fn payment_insufficient_default() -> Self {
621 Self::PaymentInsufficient(None)
622 }
623
624 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 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 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 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 _ => 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 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 #[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 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 #[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}