Skip to main content

r402_core/
error.rs

1//! Layered error types for x402 facilitator operations.
2//!
3//! The hierarchy is:
4//!
5//! ```text
6//! FacilitatorError
7//! ├── Verification(VerificationError)
8//! ├── Settlement(SettlementError)
9//! ├── Aborted { reason, message }   // hook-level veto
10//! ├── Onchain(String)               // chain RPC / tx revert
11//! └── Internal(Box<dyn Error>)      // unexpected internal failure
12//! ```
13//!
14//! Every leaf carries enough information to round-trip through the wire via
15//! [`AsPaymentProblem`], which chain-specific crates implement for their own
16//! error variants.
17
18use crate::error_reason::{AsPaymentProblem, ErrorReason, PaymentProblem};
19
20/// Verification-phase failures.
21///
22/// Returned when payload parsing, signature verification, or pre-settlement
23/// on-chain checks reject the request.
24#[derive(Debug, thiserror::Error)]
25#[non_exhaustive]
26pub enum VerificationError {
27    /// The payload JSON / binary format could not be parsed.
28    #[error("invalid format: {0}")]
29    InvalidFormat(String),
30
31    /// The payment amount does not match the requirements.
32    #[error("payment amount does not match requirements")]
33    InvalidPaymentAmount,
34
35    /// The payment authorization is not yet valid.
36    #[error("payment authorization is not yet valid")]
37    Early,
38
39    /// The payment authorization has expired.
40    #[error("payment authorization has expired")]
41    Expired,
42
43    /// The chain ID does not match requirements.
44    #[error("chain id mismatch")]
45    ChainIdMismatch,
46
47    /// The payment recipient does not match requirements.
48    #[error("payment recipient mismatch")]
49    RecipientMismatch,
50
51    /// The token asset does not match requirements.
52    #[error("payment asset mismatch")]
53    AssetMismatch,
54
55    /// On-chain balance is insufficient.
56    #[error("insufficient on-chain balance")]
57    InsufficientFunds,
58
59    /// Permit2 allowance is insufficient.
60    ///
61    /// Clients receiving this should call `approve` on the token contract
62    /// pointing at the Permit2 address and retry. HTTP transports map this
63    /// to `412 Precondition Failed`.
64    #[error("permit2 allowance required")]
65    Permit2AllowanceRequired,
66
67    /// The payment signature is invalid.
68    #[error("invalid signature: {0}")]
69    InvalidSignature(String),
70
71    /// Pre-settlement simulation failed.
72    #[error("simulation failed: {0}")]
73    SimulationFailed(String),
74
75    /// The chain is not supported by this facilitator.
76    #[error("unsupported chain")]
77    UnsupportedChain,
78
79    /// The scheme is not supported by this facilitator.
80    #[error("unsupported scheme")]
81    UnsupportedScheme,
82
83    /// Accepted details diverge from declared requirements.
84    #[error("accepted details do not match requirements")]
85    AcceptedRequirementsMismatch,
86
87    /// EIP-3009 nonce already consumed on-chain.
88    #[error("authorization nonce already used")]
89    NonceAlreadyUsed,
90
91    /// Attempted to settle a transaction whose payload was already processed.
92    #[error("duplicate settlement attempt")]
93    DuplicateSettlement,
94
95    /// Memo data does not match `extra.memo`.
96    #[error("memo data mismatch")]
97    MemoMismatch,
98
99    /// Invalid number of memo instructions (spec requires exactly one).
100    #[error("memo instruction count invalid (expected 1, got {count})")]
101    MemoInstructionCountInvalid {
102        /// Observed number of memo instructions.
103        count: usize,
104    },
105
106    /// Resource server requested a settlement amount exceeding the signed
107    /// maximum authorisation (upto scheme).
108    ///
109    /// Maps to `ErrorReason::InvalidUptoEvmPayloadSettlementExceedsAmount`
110    /// on the wire, which serialises as
111    /// `invalid_upto_evm_payload_settlement_exceeds_amount` per x402 v2 spec.
112    #[error("settlement amount {requested} exceeds authorised maximum {authorised}")]
113    SettlementAmountExceedsPermitted {
114        /// Amount the resource server asked to settle.
115        requested: String,
116        /// Maximum amount signed by the buyer.
117        authorised: String,
118    },
119
120    /// `witness.facilitator` does not match any address known to the
121    /// facilitator (upto scheme).
122    ///
123    /// Buyer either signed with a stale `paymentRequirements.extra.facilitatorAddress`
124    /// or attempted to bind the payment to a different facilitator instance.
125    /// Maps to `ErrorReason::UptoFacilitatorMismatch`.
126    #[error("witness.facilitator {witness} is not authorised on this facilitator")]
127    UptoFacilitatorMismatch {
128        /// EIP-55 checksummed `witness.facilitator` from the buyer's signed payload.
129        witness: String,
130    },
131
132    /// On-chain proxy reverted with `UnauthorizedFacilitator` because the
133    /// settle transaction was submitted from an address other than
134    /// `witness.facilitator` (upto scheme).
135    ///
136    /// Maps to `ErrorReason::UptoUnauthorizedFacilitator`.
137    #[error("on-chain proxy rejected settle: msg.sender does not match witness.facilitator")]
138    UptoUnauthorizedFacilitator,
139
140    /// On-chain proxy reverted with `AmountExceedsPermitted` (upto scheme).
141    /// Defence-in-depth signal: the off-chain pipeline should have caught
142    /// this before submission.
143    ///
144    /// Maps to `ErrorReason::UptoAmountExceedsPermitted`.
145    #[error("on-chain proxy rejected settle: amount exceeds permitted maximum")]
146    UptoAmountExceedsPermitted,
147}
148
149impl From<serde_json::Error> for VerificationError {
150    fn from(err: serde_json::Error) -> Self {
151        Self::InvalidFormat(err.to_string())
152    }
153}
154
155impl AsPaymentProblem for VerificationError {
156    fn as_payment_problem(&self) -> PaymentProblem {
157        // Chain-agnostic mappings target the spec §9 standard codes.
158        // Chain-specific facilitators may override with chain-prefixed
159        // codes (e.g. `invalid_exact_evm_payload_signature`) at their own
160        // `AsPaymentProblem` impls.
161        let reason = match self {
162            Self::InvalidFormat(_) | Self::InvalidSignature(_) | Self::Early | Self::Expired => {
163                ErrorReason::InvalidPayload
164            }
165            Self::InvalidPaymentAmount
166            | Self::RecipientMismatch
167            | Self::AssetMismatch
168            | Self::AcceptedRequirementsMismatch => ErrorReason::InvalidPaymentRequirements,
169            Self::ChainIdMismatch | Self::UnsupportedChain => ErrorReason::InvalidNetwork,
170            Self::InsufficientFunds => ErrorReason::InsufficientFunds,
171            Self::Permit2AllowanceRequired => ErrorReason::Permit2AllowanceRequired,
172            Self::SimulationFailed(_) => ErrorReason::InvalidTransactionState,
173            Self::UnsupportedScheme => ErrorReason::UnsupportedScheme,
174            Self::NonceAlreadyUsed => ErrorReason::NonceAlreadyUsed,
175            Self::DuplicateSettlement => ErrorReason::DuplicateSettlement,
176            Self::MemoMismatch => ErrorReason::InvalidExactSolanaPayloadMemoMismatch,
177            Self::MemoInstructionCountInvalid { .. } => {
178                ErrorReason::InvalidExactSolanaPayloadMemoCount
179            }
180            Self::SettlementAmountExceedsPermitted { .. } => {
181                ErrorReason::InvalidUptoEvmPayloadSettlementExceedsAmount
182            }
183            Self::UptoFacilitatorMismatch { .. } => ErrorReason::UptoFacilitatorMismatch,
184            Self::UptoUnauthorizedFacilitator => ErrorReason::UptoUnauthorizedFacilitator,
185            Self::UptoAmountExceedsPermitted => ErrorReason::UptoAmountExceedsPermitted,
186        };
187        PaymentProblem::new(reason, self.to_string())
188    }
189}
190
191/// Settlement-phase failures.
192#[derive(Debug, thiserror::Error)]
193#[non_exhaustive]
194pub enum SettlementError {
195    /// Settlement reverted on-chain or the RPC call failed.
196    #[error("on-chain settlement failed: {0}")]
197    Onchain(String),
198
199    /// Settlement timed out.
200    #[error("settlement timed out")]
201    Timeout,
202
203    /// Duplicate settlement detected at the cache layer.
204    #[error("duplicate settlement attempt")]
205    Duplicate,
206}
207
208impl AsPaymentProblem for SettlementError {
209    fn as_payment_problem(&self) -> PaymentProblem {
210        let reason = match self {
211            Self::Onchain(_) => ErrorReason::InvalidTransactionState,
212            Self::Timeout => ErrorReason::UnexpectedSettleError,
213            Self::Duplicate => ErrorReason::DuplicateSettlement,
214        };
215        PaymentProblem::new(reason, self.to_string())
216    }
217}
218
219/// Top-level facilitator failure.
220#[derive(Debug, thiserror::Error)]
221#[non_exhaustive]
222pub enum FacilitatorError {
223    /// Verification-phase failure.
224    #[error(transparent)]
225    Verification(#[from] VerificationError),
226
227    /// Settlement-phase failure.
228    #[error(transparent)]
229    Settlement(#[from] SettlementError),
230
231    /// Hook veto: the operation was aborted before or during its lifecycle.
232    #[error("{reason}: {message}")]
233    Aborted {
234        /// Machine-readable abort reason (e.g. `"kyt_blocked"`).
235        reason: String,
236        /// Human-readable abort description.
237        message: String,
238    },
239
240    /// On-chain or RPC failure that is not scheme-specific.
241    #[error("on-chain error: {0}")]
242    Onchain(String),
243
244    /// Any other internal error not covered above.
245    #[error(transparent)]
246    Internal(Box<dyn std::error::Error + Send + Sync>),
247}
248
249impl FacilitatorError {
250    /// Constructs an aborted variant from reason and message strings.
251    #[must_use]
252    pub fn aborted(reason: impl Into<String>, message: impl Into<String>) -> Self {
253        Self::Aborted {
254            reason: reason.into(),
255            message: message.into(),
256        }
257    }
258
259    /// Constructs an internal error from any boxed error.
260    #[must_use]
261    pub fn internal<E>(err: E) -> Self
262    where
263        E: Into<Box<dyn std::error::Error + Send + Sync>>,
264    {
265        Self::Internal(err.into())
266    }
267}
268
269impl AsPaymentProblem for FacilitatorError {
270    fn as_payment_problem(&self) -> PaymentProblem {
271        match self {
272            Self::Verification(e) => e.as_payment_problem(),
273            Self::Settlement(e) => e.as_payment_problem(),
274            // Hook-supplied reason strings round-trip through `from_wire`,
275            // which preserves any unknown code as `Custom(…)`.
276            Self::Aborted { reason, message } => PaymentProblem::new(
277                ErrorReason::from_wire(reason),
278                format!("{reason}: {message}"),
279            ),
280            Self::Onchain(message) => {
281                PaymentProblem::new(ErrorReason::InvalidTransactionState, message.clone())
282            }
283            Self::Internal(e) => {
284                PaymentProblem::new(ErrorReason::UnexpectedVerifyError, e.to_string())
285            }
286        }
287    }
288}
289
290/// Client-side error returned when constructing / signing payments.
291#[derive(Debug, thiserror::Error)]
292#[non_exhaustive]
293pub enum ClientError {
294    /// No candidate matched the client's capabilities.
295    #[error("no matching payment option")]
296    NoMatchingPaymentOption,
297
298    /// The underlying HTTP body cannot be retried (streaming, etc.).
299    #[error("request is not cloneable (streaming body?)")]
300    RequestNotCloneable,
301
302    /// Parsing the 402 response body failed.
303    #[error("failed to parse 402 response: {0}")]
304    Parse(String),
305
306    /// Signing the authorization failed.
307    #[error("failed to sign payment: {0}")]
308    Signing(String),
309
310    /// A required on-chain pre-condition is missing (e.g. approval).
311    #[error("payment pre-condition not met: {0}")]
312    PreConditionFailed(String),
313
314    /// JSON (de)serialisation failed.
315    #[error(transparent)]
316    Json(#[from] serde_json::Error),
317}