mod charge;
mod session;
pub use charge::{ChargeMethod, ChargeValidation};
pub use session::SessionMethod;
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ErrorCode {
Expired,
InvalidAmount,
InvalidRecipient,
TransactionFailed,
NotFound,
InvalidCredential,
NetworkError,
ChainIdMismatch,
CredentialMismatch,
ChannelNotFound,
ChannelClosed,
InsufficientBalance,
InvalidPayload,
InvalidSignature,
AmountExceedsDeposit,
DeltaTooSmall,
InvalidChallenge,
Internal,
PaymentActionRequired,
}
impl ErrorCode {
pub fn as_str(&self) -> &'static str {
match self {
Self::Expired => "expired",
Self::InvalidAmount => "invalid-amount",
Self::InvalidRecipient => "invalid-recipient",
Self::TransactionFailed => "transaction-failed",
Self::NotFound => "not-found",
Self::InvalidCredential => "invalid-credential",
Self::NetworkError => "network-error",
Self::ChainIdMismatch => "chain-id-mismatch",
Self::CredentialMismatch => "credential-mismatch",
Self::ChannelNotFound => "channel-not-found",
Self::ChannelClosed => "channel-closed",
Self::InsufficientBalance => "insufficient-balance",
Self::InvalidPayload => "invalid-payload",
Self::InvalidSignature => "invalid-signature",
Self::AmountExceedsDeposit => "amount-exceeds-deposit",
Self::DeltaTooSmall => "delta-too-small",
Self::InvalidChallenge => "invalid-challenge",
Self::Internal => "internal",
Self::PaymentActionRequired => "payment-action-required",
}
}
pub fn spec_code(&self) -> &'static str {
MppError::from(VerificationError::with_code(String::new(), *self))
.problem_type_suffix()
.unwrap_or(crate::error::INTERNAL_PROBLEM_TYPE_SUFFIX)
}
}
impl fmt::Display for ErrorCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.as_str())
}
}
#[derive(Debug, Clone)]
pub struct VerificationError {
pub message: String,
pub code: Option<ErrorCode>,
pub retryable: bool,
}
impl VerificationError {
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
code: None,
retryable: false,
}
}
pub fn with_code(message: impl Into<String>, code: ErrorCode) -> Self {
Self {
message: message.into(),
code: Some(code),
retryable: false,
}
}
pub fn retryable(mut self) -> Self {
self.retryable = true;
self
}
pub fn expired(message: impl Into<String>) -> Self {
Self::with_code(message, ErrorCode::Expired)
}
pub fn invalid_amount(message: impl Into<String>) -> Self {
Self::with_code(message, ErrorCode::InvalidAmount)
}
pub fn invalid_recipient(message: impl Into<String>) -> Self {
Self::with_code(message, ErrorCode::InvalidRecipient)
}
pub fn transaction_failed(message: impl Into<String>) -> Self {
Self::with_code(message, ErrorCode::TransactionFailed)
}
pub fn not_found(message: impl Into<String>) -> Self {
Self::with_code(message, ErrorCode::NotFound)
}
pub fn chain_id_mismatch(message: impl Into<String>) -> Self {
Self::with_code(message, ErrorCode::ChainIdMismatch)
}
pub fn credential_mismatch(message: impl Into<String>) -> Self {
Self::with_code(message, ErrorCode::CredentialMismatch)
}
pub fn network_error(message: impl Into<String>) -> Self {
Self::with_code(message, ErrorCode::NetworkError).retryable()
}
pub fn invalid_challenge(message: impl Into<String>) -> Self {
Self::with_code(message, ErrorCode::InvalidChallenge)
}
pub fn internal(message: impl Into<String>) -> Self {
Self::with_code(message, ErrorCode::Internal)
}
pub fn payment_action_required(message: impl Into<String>) -> Self {
Self::with_code(message, ErrorCode::PaymentActionRequired)
}
pub fn pending(message: impl Into<String>) -> Self {
Self::with_code(message, ErrorCode::NotFound).retryable()
}
pub fn channel_not_found(message: impl Into<String>) -> Self {
Self::with_code(message, ErrorCode::ChannelNotFound)
}
pub fn channel_closed(message: impl Into<String>) -> Self {
Self::with_code(message, ErrorCode::ChannelClosed)
}
pub fn insufficient_balance(message: impl Into<String>) -> Self {
Self::with_code(message, ErrorCode::InsufficientBalance)
}
pub fn invalid_payload(message: impl Into<String>) -> Self {
Self::with_code(message, ErrorCode::InvalidPayload)
}
pub fn invalid_signature(message: impl Into<String>) -> Self {
Self::with_code(message, ErrorCode::InvalidSignature)
}
pub fn amount_exceeds_deposit(message: impl Into<String>) -> Self {
Self::with_code(message, ErrorCode::AmountExceedsDeposit)
}
pub fn delta_too_small(message: impl Into<String>) -> Self {
Self::with_code(message, ErrorCode::DeltaTooSmall)
}
}
impl fmt::Display for VerificationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if let Some(ref code) = self.code {
write!(f, "[{}] {}", code, self.message)
} else {
write!(f, "{}", self.message)
}
}
}
impl std::error::Error for VerificationError {}
impl From<String> for VerificationError {
fn from(message: String) -> Self {
Self::new(message)
}
}
impl From<&str> for VerificationError {
fn from(message: &str) -> Self {
Self::new(message)
}
}
use crate::error::{MppError, PaymentError, PaymentErrorDetails};
impl From<VerificationError> for MppError {
fn from(err: VerificationError) -> Self {
if err.retryable {
return MppError::Internal(err.message);
}
match err.code {
Some(ErrorCode::Expired) => MppError::PaymentExpired(None),
Some(ErrorCode::InvalidChallenge) => MppError::invalid_challenge_reason(err.message),
Some(ErrorCode::NetworkError) | Some(ErrorCode::Internal) => {
MppError::Internal(err.message)
}
Some(ErrorCode::InvalidCredential) => MppError::MalformedCredential(Some(err.message)),
Some(ErrorCode::ChannelNotFound) => MppError::ChannelNotFound(Some(err.message)),
Some(ErrorCode::ChannelClosed) => MppError::ChannelClosed(Some(err.message)),
Some(ErrorCode::InsufficientBalance) => {
MppError::InsufficientBalance(Some(err.message))
}
Some(ErrorCode::InvalidPayload) => MppError::InvalidPayload(Some(err.message)),
Some(ErrorCode::InvalidSignature) => MppError::InvalidSignature(Some(err.message)),
Some(ErrorCode::AmountExceedsDeposit) => {
MppError::AmountExceedsDeposit(Some(err.message))
}
Some(ErrorCode::DeltaTooSmall) => MppError::DeltaTooSmall(Some(err.message)),
Some(ErrorCode::PaymentActionRequired) => {
MppError::PaymentActionRequired(Some(err.message))
}
Some(ErrorCode::CredentialMismatch)
| Some(ErrorCode::InvalidAmount)
| Some(ErrorCode::InvalidRecipient)
| Some(ErrorCode::TransactionFailed)
| Some(ErrorCode::ChainIdMismatch)
| Some(ErrorCode::NotFound)
| None => MppError::VerificationFailed(Some(err.message)),
}
}
}
impl PaymentError for VerificationError {
fn to_problem_details(&self, challenge_id: Option<&str>) -> PaymentErrorDetails {
MppError::from(self.clone()).to_problem_details(challenge_id)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_verification_error_display() {
let err = VerificationError::new("Payment failed");
assert_eq!(err.to_string(), "Payment failed");
let err_with_code = VerificationError::with_code("Request expired", ErrorCode::Expired);
assert_eq!(err_with_code.to_string(), "[expired] Request expired");
}
#[test]
fn test_verification_error_constructors() {
let err = VerificationError::expired("Challenge expired");
assert_eq!(err.code, Some(ErrorCode::Expired));
assert!(!err.retryable);
let err = VerificationError::invalid_amount("Amount mismatch").retryable();
assert_eq!(err.code, Some(ErrorCode::InvalidAmount));
assert!(err.retryable);
}
const SPEC_PROBLEMS: &[(&str, u16)] = &[
("payment-required", 402),
("payment-insufficient", 402),
("payment-expired", 402),
("verification-failed", 402),
("method-unsupported", 400),
("malformed-credential", 402),
("invalid-challenge", 402),
("bad-request", 400),
("invalid-payload", 402),
("internal-payment-error", 500),
("payment-action-required", 402),
("session/invalid-signature", 402),
("session/signer-mismatch", 402),
("session/amount-exceeds-deposit", 402),
("session/delta-too-small", 402),
("session/channel-not-found", 410),
("session/channel-finalized", 410),
("session/insufficient-balance", 402),
];
#[track_caller]
fn assert_spec_problem(label: &str, problem: PaymentErrorDetails, suffix: &str) {
let (_, status) = SPEC_PROBLEMS
.iter()
.find(|(problem_type, _)| *problem_type == suffix)
.unwrap_or_else(|| panic!("{label}: {suffix} is not a spec problem type"));
assert_eq!(
problem.problem_type,
format!("{}/{suffix}", crate::error::CORE_PROBLEM_TYPE_BASE),
"{label}"
);
assert_eq!(problem.status, *status, "{label}");
if suffix == "internal-payment-error" {
assert_eq!(
problem.detail, "An internal payment error occurred.",
"{label}"
);
}
}
#[test]
fn test_problem_mapping_matches_spec() {
let reason = || Some("reason".to_string());
let errors = [
(
MppError::MalformedCredential(reason()),
"malformed-credential",
),
(
MppError::InvalidChallenge {
id: None,
reason: reason(),
},
"invalid-challenge",
),
(
MppError::VerificationFailed(reason()),
"verification-failed",
),
(MppError::PaymentExpired(reason()), "payment-expired"),
(
MppError::PaymentRequired {
realm: None,
description: None,
},
"payment-required",
),
(MppError::InvalidPayload(reason()), "invalid-payload"),
(MppError::BadRequest(reason()), "bad-request"),
(
MppError::UnsupportedPaymentMethod("method".into()),
"method-unsupported",
),
(
MppError::PaymentActionRequired(reason()),
"payment-action-required",
),
(
MppError::PaymentInsufficient(reason()),
"payment-insufficient",
),
(
MppError::InsufficientBalance(reason()),
"session/insufficient-balance",
),
(
MppError::InvalidSignature(reason()),
"session/invalid-signature",
),
(
MppError::SignerMismatch(reason()),
"session/signer-mismatch",
),
(
MppError::AmountExceedsDeposit(reason()),
"session/amount-exceeds-deposit",
),
(MppError::DeltaTooSmall(reason()), "session/delta-too-small"),
(
MppError::ChannelNotFound(reason()),
"session/channel-not-found",
),
(
MppError::ChannelClosed(reason()),
"session/channel-finalized",
),
(
MppError::Internal("store unavailable".into()),
"internal-payment-error",
),
(
MppError::Http("rpc unavailable".into()),
"internal-payment-error",
),
];
for (error, suffix) in errors {
let label = format!("{error:?}");
let payment_problem = (suffix != "internal-payment-error").then_some(suffix);
assert_eq!(error.problem_type_suffix(), payment_problem, "{label}");
assert_spec_problem(&label, error.to_problem_details(None), suffix);
}
let codes = [
(ErrorCode::Expired, "payment-expired"),
(ErrorCode::InvalidAmount, "verification-failed"),
(ErrorCode::InvalidRecipient, "verification-failed"),
(ErrorCode::TransactionFailed, "verification-failed"),
(ErrorCode::NotFound, "verification-failed"),
(ErrorCode::InvalidCredential, "malformed-credential"),
(ErrorCode::NetworkError, "internal-payment-error"),
(ErrorCode::ChainIdMismatch, "verification-failed"),
(ErrorCode::CredentialMismatch, "verification-failed"),
(ErrorCode::ChannelNotFound, "session/channel-not-found"),
(ErrorCode::ChannelClosed, "session/channel-finalized"),
(
ErrorCode::InsufficientBalance,
"session/insufficient-balance",
),
(ErrorCode::InvalidPayload, "invalid-payload"),
(ErrorCode::InvalidSignature, "session/invalid-signature"),
(
ErrorCode::AmountExceedsDeposit,
"session/amount-exceeds-deposit",
),
(ErrorCode::DeltaTooSmall, "session/delta-too-small"),
(ErrorCode::InvalidChallenge, "invalid-challenge"),
(ErrorCode::Internal, "internal-payment-error"),
(ErrorCode::PaymentActionRequired, "payment-action-required"),
];
for (code, suffix) in codes {
let label = format!("{code:?}");
assert_eq!(code.spec_code(), suffix, "{label}");
let error = VerificationError::with_code("message", code);
assert_spec_problem(&label, error.to_problem_details(None), suffix);
}
assert_spec_problem(
"no code",
VerificationError::new("message").to_problem_details(None),
"verification-failed",
);
for (label, error) in [
("pending", VerificationError::pending("not yet mined")),
("network", VerificationError::network_error("rpc down")),
(
"retryable",
VerificationError::invalid_amount("message").retryable(),
),
] {
assert_spec_problem(
label,
error.to_problem_details(None),
"internal-payment-error",
);
}
}
}