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