Skip to main content

r402_protocol/error/
mod.rs

1//! Layered error types for x402 facilitator operations.
2//!
3//! ```text
4//! FacilitatorError
5//! ├── Verification(VerificationError)
6//! ├── Settlement(SettlementError)
7//! ├── Aborted { reason, message }
8//! ├── Onchain(String)
9//! ├── Transport { kind: FacilitatorTransportKind }
10//! └── Internal(Box<dyn Error>)
11//! ```
12//!
13//! HTTP maps [`FacilitatorError::Transport`] to 502. Well-formed verify/settle
14//! JSON on 4xx/5xx stays [`FacilitatorError::Verification`] /
15//! [`FacilitatorError::Settlement`] (402).
16
17mod problem;
18mod reason;
19
20pub use problem::{AsPaymentProblem, PaymentProblem};
21pub use reason::ErrorReason;
22
23/// Verification-phase failures.
24#[derive(Debug, thiserror::Error)]
25#[non_exhaustive]
26pub enum VerificationError {
27    /// Payload JSON / binary could not be parsed.
28    #[error("invalid format: {0}")]
29    InvalidFormat(String),
30    /// Payment amount is below the required amount.
31    #[error("payment amount is below requirements")]
32    InvalidPaymentAmount,
33    /// Authorization is not yet valid.
34    #[error("payment authorization is not yet valid")]
35    Early,
36    /// Authorization has expired.
37    #[error("payment authorization has expired")]
38    Expired,
39    /// Chain ID does not match requirements.
40    #[error("chain id mismatch")]
41    ChainIdMismatch,
42    /// Recipient does not match requirements.
43    #[error("payment recipient mismatch")]
44    RecipientMismatch,
45    /// Token asset does not match requirements.
46    #[error("payment asset mismatch")]
47    AssetMismatch,
48    /// On-chain balance is insufficient.
49    #[error("insufficient on-chain balance")]
50    InsufficientFunds,
51    /// Permit2 allowance is insufficient. HTTP mapping: 412.
52    #[error("permit2 allowance required")]
53    Permit2AllowanceRequired,
54    /// Signature is invalid.
55    #[error("invalid signature: {0}")]
56    InvalidSignature(String),
57    /// Pre-settlement simulation failed.
58    #[error("simulation failed: {0}")]
59    SimulationFailed(String),
60    /// Chain is not supported by this facilitator.
61    #[error("unsupported chain")]
62    UnsupportedChain,
63    /// Scheme is not supported by this facilitator.
64    #[error("unsupported scheme")]
65    UnsupportedScheme,
66    /// Accepted details diverge from declared requirements.
67    #[error("accepted details do not match requirements")]
68    AcceptedRequirementsMismatch,
69    /// EIP-3009 nonce already consumed on-chain.
70    #[error("authorization nonce already used")]
71    NonceAlreadyUsed,
72    /// Payload was already processed.
73    #[error("duplicate settlement attempt")]
74    DuplicateSettlement,
75    /// Memo data does not match `extra.memo`.
76    #[error("memo data mismatch")]
77    MemoMismatch,
78    /// Invalid number of memo instructions (spec requires exactly one).
79    #[error("memo instruction count invalid (expected 1, got {count})")]
80    MemoInstructionCountInvalid {
81        /// Observed number of memo instructions.
82        count: usize,
83    },
84    /// Requested settlement exceeds the signed maximum (upto).
85    #[error("settlement amount {requested} exceeds authorised maximum {authorised}")]
86    SettlementAmountExceedsPermitted {
87        /// Amount the resource server asked to settle.
88        requested: String,
89        /// Maximum amount signed by the buyer.
90        authorised: String,
91    },
92    /// `witness.facilitator` is not an address known to this facilitator.
93    #[error("witness.facilitator {witness} is not authorised on this facilitator")]
94    UptoFacilitatorMismatch {
95        /// EIP-55 checksummed `witness.facilitator` from the signed payload.
96        witness: String,
97    },
98    /// On-chain proxy reverted with `UnauthorizedFacilitator`.
99    #[error("on-chain proxy rejected settle: msg.sender does not match witness.facilitator")]
100    UptoUnauthorizedFacilitator,
101    /// On-chain proxy reverted with `AmountExceedsPermitted`.
102    #[error("on-chain proxy rejected settle: amount exceeds permitted maximum")]
103    UptoAmountExceedsPermitted,
104    /// Official scheme wire reason not represented by a dedicated variant.
105    #[error("{0}")]
106    Wire(ErrorReason),
107    /// Client extension echo does not match the server advertisement.
108    #[error("extension_echo_mismatch")]
109    ExtensionEchoMismatch {
110        /// Extension key that failed echo validation.
111        extension_key: String,
112    },
113}
114
115impl VerificationError {
116    /// Wraps an official scheme wire code (known variant or [`ErrorReason::Custom`]).
117    #[must_use]
118    pub fn from_wire(code: &str) -> Self {
119        Self::Wire(ErrorReason::from_wire(code))
120    }
121}
122
123impl From<serde_json::Error> for VerificationError {
124    fn from(err: serde_json::Error) -> Self {
125        Self::InvalidFormat(err.to_string())
126    }
127}
128
129impl AsPaymentProblem for VerificationError {
130    fn as_payment_problem(&self) -> PaymentProblem {
131        let reason = match self {
132            Self::InvalidFormat(_) | Self::InvalidSignature(_) | Self::Early | Self::Expired => {
133                ErrorReason::InvalidPayload
134            }
135            Self::InvalidPaymentAmount
136            | Self::RecipientMismatch
137            | Self::AssetMismatch
138            | Self::AcceptedRequirementsMismatch => ErrorReason::InvalidPaymentRequirements,
139            Self::ChainIdMismatch | Self::UnsupportedChain => ErrorReason::InvalidNetwork,
140            Self::InsufficientFunds => ErrorReason::InsufficientFunds,
141            Self::Permit2AllowanceRequired => ErrorReason::Permit2AllowanceRequired,
142            Self::SimulationFailed(_) => ErrorReason::InvalidTransactionState,
143            Self::UnsupportedScheme => ErrorReason::UnsupportedScheme,
144            Self::NonceAlreadyUsed => ErrorReason::NonceAlreadyUsed,
145            Self::DuplicateSettlement => ErrorReason::DuplicateSettlement,
146            Self::MemoMismatch => ErrorReason::InvalidExactSolanaPayloadMemoMismatch,
147            Self::MemoInstructionCountInvalid { .. } => {
148                ErrorReason::InvalidExactSolanaPayloadMemoCount
149            }
150            Self::SettlementAmountExceedsPermitted { .. } => {
151                ErrorReason::InvalidUptoEvmPayloadSettlementExceedsAmount
152            }
153            Self::UptoFacilitatorMismatch { .. } => ErrorReason::UptoFacilitatorMismatch,
154            Self::UptoUnauthorizedFacilitator => ErrorReason::UptoUnauthorizedFacilitator,
155            Self::UptoAmountExceedsPermitted => ErrorReason::UptoAmountExceedsPermitted,
156            Self::Wire(reason) => reason.clone(),
157            Self::ExtensionEchoMismatch { .. } => ErrorReason::ExtensionEchoMismatch,
158        };
159        PaymentProblem::new(reason, self.to_string())
160    }
161}
162
163/// Settlement-phase failures.
164#[derive(Debug, thiserror::Error)]
165#[non_exhaustive]
166pub enum SettlementError {
167    /// Settlement reverted on-chain or the RPC call failed.
168    #[error("on-chain settlement failed: {0}")]
169    Onchain(String),
170    /// Settlement timed out.
171    #[error("settlement timed out")]
172    Timeout,
173    /// Duplicate settlement detected at the cache layer.
174    #[error("duplicate settlement attempt")]
175    Duplicate,
176}
177
178impl AsPaymentProblem for SettlementError {
179    fn as_payment_problem(&self) -> PaymentProblem {
180        let reason = match self {
181            Self::Onchain(_) => ErrorReason::InvalidTransactionState,
182            Self::Timeout => ErrorReason::UnexpectedSettleError,
183            Self::Duplicate => ErrorReason::DuplicateSettlement,
184        };
185        PaymentProblem::new(reason, self.to_string())
186    }
187}
188
189/// Facilitator transport failure (HTTP 502).
190///
191/// `HttpStatus` is a non-2xx response whose body is not well-formed
192/// verify/settle JSON. `MalformedSuccessBody` is 2xx whose JSON is not a
193/// [`crate::payment::VerifyResponse`] / [`crate::payment::SettleResponse`].
194/// `Io` is connect/DNS/TLS/body-read (or auth-header resolution) before a
195/// complete mapped HTTP response.
196#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
197#[non_exhaustive]
198pub enum FacilitatorTransportKind {
199    /// Client-side wait elapsed before a response.
200    #[error("facilitator request timed out")]
201    Timeout,
202    /// Non-2xx HTTP status without a well-formed verify/settle body.
203    #[error("facilitator HTTP status {status}")]
204    HttpStatus {
205        /// HTTP status code.
206        status: u16,
207    },
208    /// 2xx body is not a `VerifyResponse` / `SettleResponse`.
209    #[error("facilitator returned a malformed success body")]
210    MalformedSuccessBody,
211    /// Connect, DNS, TLS, body-read, or auth-header resolution failed.
212    #[error("facilitator I/O failure")]
213    Io,
214}
215
216/// Top-level facilitator failure.
217#[derive(Debug, thiserror::Error)]
218#[non_exhaustive]
219pub enum FacilitatorError {
220    /// Verification-phase failure.
221    #[error(transparent)]
222    Verification(#[from] VerificationError),
223    /// Settlement-phase failure.
224    #[error(transparent)]
225    Settlement(#[from] SettlementError),
226    /// Hook veto before or during the operation.
227    #[error("{reason}: {message}")]
228    Aborted {
229        /// Machine-readable abort reason (e.g. `"kyt_blocked"`).
230        reason: String,
231        /// Human-readable abort description.
232        message: String,
233    },
234    /// On-chain or RPC failure that is not scheme-specific.
235    #[error("on-chain error: {0}")]
236    Onchain(String),
237    /// Transport failure. HTTP mapping: 502.
238    #[error("{kind}")]
239    Transport {
240        /// Transport failure kind.
241        kind: FacilitatorTransportKind,
242    },
243    /// Unexpected internal error.
244    #[error(transparent)]
245    Internal(Box<dyn std::error::Error + Send + Sync>),
246}
247
248impl FacilitatorError {
249    /// Constructs an aborted variant.
250    #[must_use]
251    pub fn aborted(reason: impl Into<String>, message: impl Into<String>) -> Self {
252        Self::Aborted {
253            reason: reason.into(),
254            message: message.into(),
255        }
256    }
257
258    /// Constructs a transport failure.
259    #[must_use]
260    pub const fn transport(kind: FacilitatorTransportKind) -> Self {
261        Self::Transport { kind }
262    }
263
264    /// Returns `true` when this is a transport failure.
265    #[must_use]
266    pub const fn is_transport(&self) -> bool {
267        matches!(self, Self::Transport { .. })
268    }
269
270    /// Constructs an internal error from any boxed error.
271    #[must_use]
272    pub fn internal<E>(err: E) -> Self
273    where
274        E: Into<Box<dyn std::error::Error + Send + Sync>>,
275    {
276        Self::Internal(err.into())
277    }
278
279    /// 402 payment problem. `None` for [`Self::Transport`] (HTTP 502, not 402).
280    #[must_use]
281    pub fn as_payment_problem(&self) -> Option<PaymentProblem> {
282        match self {
283            Self::Verification(e) => Some(e.as_payment_problem()),
284            Self::Settlement(e) => Some(e.as_payment_problem()),
285            Self::Aborted { reason, message } => Some(PaymentProblem::new(
286                ErrorReason::from_wire(reason),
287                format!("{reason}: {message}"),
288            )),
289            Self::Onchain(message) => Some(PaymentProblem::new(
290                ErrorReason::InvalidTransactionState,
291                message.clone(),
292            )),
293            Self::Transport { .. } => None,
294            Self::Internal(e) => Some(PaymentProblem::new(
295                ErrorReason::UnexpectedVerifyError,
296                e.to_string(),
297            )),
298        }
299    }
300}
301
302impl From<FacilitatorTransportKind> for FacilitatorError {
303    fn from(kind: FacilitatorTransportKind) -> Self {
304        Self::Transport { kind }
305    }
306}
307
308/// Client-side error returned when constructing or signing payments.
309#[derive(Debug, thiserror::Error)]
310#[non_exhaustive]
311pub enum ClientError {
312    /// No candidate matched the client's capabilities.
313    #[error("no matching payment option")]
314    NoMatchingPaymentOption,
315    /// The underlying HTTP body cannot be retried (streaming, etc.).
316    #[error("request is not cloneable (streaming body?)")]
317    RequestNotCloneable,
318    /// Parsing the 402 response body failed.
319    #[error("failed to parse 402 response: {0}")]
320    Parse(String),
321    /// Signing the authorization failed.
322    #[error("failed to sign payment: {0}")]
323    Signing(String),
324    /// A required on-chain pre-condition is missing (e.g. approval).
325    #[error("payment pre-condition not met: {0}")]
326    PreConditionFailed(String),
327    /// JSON (de)serialisation failed.
328    #[error(transparent)]
329    Json(#[from] serde_json::Error),
330    /// Spend controls rejected every remaining payment option.
331    #[error("{0}")]
332    SpendControls(String),
333    /// No remaining accept has a recognized `extra.paymentFlow`.
334    #[error("no payment requirements with a recognized paymentFlow")]
335    UnrecognizedPaymentFlow,
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    #[test]
343    fn transport_is_distinct_from_verification() {
344        let err = FacilitatorError::transport(FacilitatorTransportKind::Timeout);
345        assert!(err.is_transport());
346        assert!(!matches!(err, FacilitatorError::Verification(_)));
347        assert!(
348            err.as_payment_problem().is_none(),
349            "transport is HTTP 502, not a 402 payment problem"
350        );
351    }
352
353    #[test]
354    fn http_status_kind_preserves_code() {
355        let kind = FacilitatorTransportKind::HttpStatus { status: 503 };
356        assert_eq!(kind.to_string(), "facilitator HTTP status 503");
357        let err = FacilitatorError::from(kind);
358        match err {
359            FacilitatorError::Transport {
360                kind: FacilitatorTransportKind::HttpStatus { status },
361            } => assert_eq!(status, 503, "status must round-trip"),
362            other => panic!("expected transport, got {other:?}"),
363        }
364    }
365
366    #[test]
367    fn io_kind_is_transport_not_payment_problem() {
368        let err = FacilitatorError::transport(FacilitatorTransportKind::Io);
369        assert!(err.is_transport());
370        assert!(
371            err.as_payment_problem().is_none(),
372            "I/O transport is HTTP 502, not a 402 payment problem"
373        );
374    }
375
376    #[test]
377    fn from_wire_preserves_official_exact_codes() {
378        let err = VerificationError::from_wire("eip6492_factory_not_allowed");
379        assert_eq!(
380            err.as_payment_problem().reason().as_str(),
381            "eip6492_factory_not_allowed"
382        );
383        let deployed = VerificationError::from_wire("asset_not_deployed_contract");
384        assert_eq!(
385            deployed.as_payment_problem().reason().as_str(),
386            "asset_not_deployed_contract"
387        );
388        let mismatch = VerificationError::from_wire("invalid_exact_evm_transfer_event_mismatch");
389        assert_eq!(
390            mismatch.as_payment_problem().reason().as_str(),
391            "invalid_exact_evm_transfer_event_mismatch"
392        );
393    }
394
395    #[test]
396    fn extension_echo_mismatch_maps_to_wire_reason() {
397        let err = VerificationError::ExtensionEchoMismatch {
398            extension_key: "builder-code".into(),
399        };
400        let problem = err.as_payment_problem();
401        assert_eq!(problem.reason(), ErrorReason::ExtensionEchoMismatch);
402        let wrapped = FacilitatorError::from(err);
403        let Some(wrapped_problem) = wrapped.as_payment_problem() else {
404            panic!("verification errors must map to a 402 payment problem");
405        };
406        assert_eq!(wrapped_problem.reason(), ErrorReason::ExtensionEchoMismatch);
407    }
408}