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