mod problem;
mod reason;
pub use problem::{AsPaymentProblem, PaymentProblem};
pub use reason::ErrorReason;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum VerificationError {
#[error("invalid format: {0}")]
InvalidFormat(String),
#[error("payment amount is below requirements")]
InvalidPaymentAmount,
#[error("payment authorization is not yet valid")]
Early,
#[error("payment authorization has expired")]
Expired,
#[error("chain id mismatch")]
ChainIdMismatch,
#[error("payment recipient mismatch")]
RecipientMismatch,
#[error("payment asset mismatch")]
AssetMismatch,
#[error("insufficient on-chain balance")]
InsufficientFunds,
#[error("permit2 allowance required")]
Permit2AllowanceRequired,
#[error("invalid signature: {0}")]
InvalidSignature(String),
#[error("simulation failed: {0}")]
SimulationFailed(String),
#[error("unsupported chain")]
UnsupportedChain,
#[error("unsupported scheme")]
UnsupportedScheme,
#[error("accepted details do not match requirements")]
AcceptedRequirementsMismatch,
#[error("authorization nonce already used")]
NonceAlreadyUsed,
#[error("duplicate settlement attempt")]
DuplicateSettlement,
#[error("memo data mismatch")]
MemoMismatch,
#[error("memo instruction count invalid (expected 1, got {count})")]
MemoInstructionCountInvalid {
count: usize,
},
#[error("settlement amount {requested} exceeds authorised maximum {authorised}")]
SettlementAmountExceedsPermitted {
requested: String,
authorised: String,
},
#[error("witness.facilitator {witness} is not authorised on this facilitator")]
UptoFacilitatorMismatch {
witness: String,
},
#[error("on-chain proxy rejected settle: msg.sender does not match witness.facilitator")]
UptoUnauthorizedFacilitator,
#[error("on-chain proxy rejected settle: amount exceeds permitted maximum")]
UptoAmountExceedsPermitted,
#[error("{0}")]
Wire(ErrorReason),
#[error("extension_echo_mismatch")]
ExtensionEchoMismatch {
extension_key: String,
},
}
impl VerificationError {
#[must_use]
pub fn from_wire(code: &str) -> Self {
Self::Wire(ErrorReason::from_wire(code))
}
}
impl From<serde_json::Error> for VerificationError {
fn from(err: serde_json::Error) -> Self {
Self::InvalidFormat(err.to_string())
}
}
impl AsPaymentProblem for VerificationError {
fn as_payment_problem(&self) -> PaymentProblem {
let reason = match self {
Self::InvalidFormat(_) | Self::InvalidSignature(_) | Self::Early | Self::Expired => {
ErrorReason::InvalidPayload
}
Self::InvalidPaymentAmount
| Self::RecipientMismatch
| Self::AssetMismatch
| Self::AcceptedRequirementsMismatch => ErrorReason::InvalidPaymentRequirements,
Self::ChainIdMismatch | Self::UnsupportedChain => ErrorReason::InvalidNetwork,
Self::InsufficientFunds => ErrorReason::InsufficientFunds,
Self::Permit2AllowanceRequired => ErrorReason::Permit2AllowanceRequired,
Self::SimulationFailed(_) => ErrorReason::InvalidTransactionState,
Self::UnsupportedScheme => ErrorReason::UnsupportedScheme,
Self::NonceAlreadyUsed => ErrorReason::NonceAlreadyUsed,
Self::DuplicateSettlement => ErrorReason::DuplicateSettlement,
Self::MemoMismatch => ErrorReason::InvalidExactSolanaPayloadMemoMismatch,
Self::MemoInstructionCountInvalid { .. } => {
ErrorReason::InvalidExactSolanaPayloadMemoCount
}
Self::SettlementAmountExceedsPermitted { .. } => {
ErrorReason::InvalidUptoEvmPayloadSettlementExceedsAmount
}
Self::UptoFacilitatorMismatch { .. } => ErrorReason::UptoFacilitatorMismatch,
Self::UptoUnauthorizedFacilitator => ErrorReason::UptoUnauthorizedFacilitator,
Self::UptoAmountExceedsPermitted => ErrorReason::UptoAmountExceedsPermitted,
Self::Wire(reason) => reason.clone(),
Self::ExtensionEchoMismatch { .. } => ErrorReason::ExtensionEchoMismatch,
};
PaymentProblem::new(reason, self.to_string())
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum SettlementError {
#[error("on-chain settlement failed: {0}")]
Onchain(String),
#[error("settlement timed out")]
Timeout,
#[error("duplicate settlement attempt")]
Duplicate,
}
impl AsPaymentProblem for SettlementError {
fn as_payment_problem(&self) -> PaymentProblem {
let reason = match self {
Self::Onchain(_) => ErrorReason::InvalidTransactionState,
Self::Timeout => ErrorReason::UnexpectedSettleError,
Self::Duplicate => ErrorReason::DuplicateSettlement,
};
PaymentProblem::new(reason, self.to_string())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum FacilitatorTransportKind {
#[error("facilitator request timed out")]
Timeout,
#[error("facilitator HTTP status {status}")]
HttpStatus {
status: u16,
},
#[error("facilitator returned a malformed success body")]
MalformedSuccessBody,
#[error("facilitator I/O failure")]
Io,
#[error("chain RPC missing a required method")]
RpcMethodMissing,
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum FacilitatorError {
#[error(transparent)]
Verification(#[from] VerificationError),
#[error(transparent)]
Settlement(#[from] SettlementError),
#[error("{reason}: {message}")]
Aborted {
reason: String,
message: String,
},
#[error("on-chain error: {0}")]
Onchain(String),
#[error("{kind}")]
Transport {
kind: FacilitatorTransportKind,
},
#[error(transparent)]
Internal(Box<dyn std::error::Error + Send + Sync>),
}
impl FacilitatorError {
#[must_use]
pub fn aborted(reason: impl Into<String>, message: impl Into<String>) -> Self {
Self::Aborted {
reason: reason.into(),
message: message.into(),
}
}
#[must_use]
pub const fn transport(kind: FacilitatorTransportKind) -> Self {
Self::Transport { kind }
}
#[must_use]
pub const fn is_transport(&self) -> bool {
matches!(self, Self::Transport { .. })
}
#[must_use]
pub fn internal<E>(err: E) -> Self
where
E: Into<Box<dyn std::error::Error + Send + Sync>>,
{
Self::Internal(err.into())
}
#[must_use]
pub fn as_payment_problem(&self) -> Option<PaymentProblem> {
match self {
Self::Verification(e) => Some(e.as_payment_problem()),
Self::Settlement(e) => Some(e.as_payment_problem()),
Self::Aborted { reason, message } => Some(PaymentProblem::new(
ErrorReason::from_wire(reason),
format!("{reason}: {message}"),
)),
Self::Onchain(message) => Some(PaymentProblem::new(
ErrorReason::InvalidTransactionState,
message.clone(),
)),
Self::Transport { .. } => None,
Self::Internal(e) => Some(PaymentProblem::new(
ErrorReason::UnexpectedVerifyError,
e.to_string(),
)),
}
}
}
impl From<FacilitatorTransportKind> for FacilitatorError {
fn from(kind: FacilitatorTransportKind) -> Self {
Self::Transport { kind }
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ClientError {
#[error("no matching payment option")]
NoMatchingPaymentOption,
#[error("request is not cloneable (streaming body?)")]
RequestNotCloneable,
#[error("failed to parse 402 response: {0}")]
Parse(String),
#[error("failed to sign payment: {0}")]
Signing(String),
#[error("payment pre-condition not met: {0}")]
PreConditionFailed(String),
#[error(transparent)]
Json(#[from] serde_json::Error),
#[error("{0}")]
SpendControls(String),
#[error("no payment requirements with a recognized paymentFlow")]
UnrecognizedPaymentFlow,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn transport_is_distinct_from_verification() {
let err = FacilitatorError::transport(FacilitatorTransportKind::Timeout);
assert!(err.is_transport());
assert!(!matches!(err, FacilitatorError::Verification(_)));
assert!(
err.as_payment_problem().is_none(),
"transport is HTTP 502, not a 402 payment problem"
);
}
#[test]
fn http_status_kind_preserves_code() {
let kind = FacilitatorTransportKind::HttpStatus { status: 503 };
assert_eq!(kind.to_string(), "facilitator HTTP status 503");
let err = FacilitatorError::from(kind);
match err {
FacilitatorError::Transport {
kind: FacilitatorTransportKind::HttpStatus { status },
} => assert_eq!(status, 503, "status must round-trip"),
other => panic!("expected transport, got {other:?}"),
}
}
#[test]
fn io_kind_is_transport_not_payment_problem() {
let err = FacilitatorError::transport(FacilitatorTransportKind::Io);
assert!(err.is_transport());
assert!(
err.as_payment_problem().is_none(),
"I/O transport is HTTP 502, not a 402 payment problem"
);
}
#[test]
fn rpc_method_missing_is_transport_not_payment_problem() {
let kind = FacilitatorTransportKind::RpcMethodMissing;
assert_eq!(kind.to_string(), "chain RPC missing a required method");
let err = FacilitatorError::transport(kind);
assert!(err.is_transport());
assert!(
err.as_payment_problem().is_none(),
"missing RPC method is HTTP 502, not a 402 payment problem"
);
}
#[test]
fn from_wire_preserves_official_exact_codes() {
let err = VerificationError::from_wire("eip6492_factory_not_allowed");
assert_eq!(
err.as_payment_problem().reason().as_str(),
"eip6492_factory_not_allowed"
);
let deployed = VerificationError::from_wire("asset_not_deployed_contract");
assert_eq!(
deployed.as_payment_problem().reason().as_str(),
"asset_not_deployed_contract"
);
let mismatch = VerificationError::from_wire("invalid_exact_evm_transfer_event_mismatch");
assert_eq!(
mismatch.as_payment_problem().reason().as_str(),
"invalid_exact_evm_transfer_event_mismatch"
);
}
#[test]
fn extension_echo_mismatch_maps_to_wire_reason() {
let err = VerificationError::ExtensionEchoMismatch {
extension_key: "builder-code".into(),
};
let problem = err.as_payment_problem();
assert_eq!(problem.reason(), ErrorReason::ExtensionEchoMismatch);
let wrapped = FacilitatorError::from(err);
let Some(wrapped_problem) = wrapped.as_payment_problem() else {
panic!("verification errors must map to a 402 payment problem");
};
assert_eq!(wrapped_problem.reason(), ErrorReason::ExtensionEchoMismatch);
}
}