Skip to main content

r402_core/
error_reason.rs

1//! Machine-readable error codes used in x402 verify/settle responses.
2//!
3//! Aligned with the x402 v2 specification §9 "Error Handling" and the
4//! cross-SDK conventions established by the Go and TypeScript reference
5//! implementations. When the wire emits an unknown code it round-trips
6//! losslessly via [`ErrorReason::Custom`].
7//!
8//! # Categories
9//!
10//! - **Spec §9 standard codes**: 15 canonical reasons every facilitator MUST
11//!   recognise (`insufficient_funds`, `invalid_payload`, ...).
12//! - **Cross-chain reserved codes**: pragmatic codes used across SDKs but
13//!   not enumerated in §9 (`duplicate_settlement`, `nonce_already_used`,
14//!   `permit2_allowance_required`).
15//! - **Upto scheme codes**: reserved by `schemes/upto/scheme_upto_evm.md`.
16//! - **SVM-prefixed codes**: chain-specific diagnostics emitted by the
17//!   Solana facilitator.
18//! - **`Custom(CompactString)`**: catch-all preserving any unknown code as
19//!   received on the wire so clients never lose information.
20
21use std::fmt::{self, Display, Formatter};
22
23use compact_str::CompactString;
24use serde::{Deserialize, Deserializer, Serialize, Serializer};
25
26/// Canonical error code returned on the wire when a payment fails.
27///
28/// The variants form a closed set of well-known codes the SDK can pattern
29/// match against. Any unknown wire code is preserved via [`Self::Custom`]
30/// so deserialisation is total and idempotent.
31///
32/// The enum is `#[non_exhaustive]`: new well-known variants may be added
33/// in minor releases without breaking semver.
34#[derive(Debug, Clone, PartialEq, Eq, Hash)]
35#[non_exhaustive]
36pub enum ErrorReason {
37    // Spec §9 standard codes.
38    /// Client does not have enough tokens to complete the payment.
39    /// Wire code: `insufficient_funds`.
40    InsufficientFunds,
41    /// Payment authorization is not yet valid (before validAfter timestamp).
42    /// Wire code: `invalid_exact_evm_payload_authorization_valid_after`.
43    InvalidExactEvmPayloadAuthorizationValidAfter,
44    /// Payment authorization has expired (after validBefore timestamp).
45    /// Wire code: `invalid_exact_evm_payload_authorization_valid_before`.
46    InvalidExactEvmPayloadAuthorizationValidBefore,
47    /// Payment amount does not exactly match the required amount.
48    /// Wire code: `invalid_exact_evm_payload_authorization_value_mismatch`.
49    InvalidExactEvmPayloadAuthorizationValueMismatch,
50    /// Payment authorization signature is invalid or improperly signed.
51    /// Wire code: `invalid_exact_evm_payload_signature`.
52    InvalidExactEvmPayloadSignature,
53    /// Recipient address does not match payment requirements.
54    /// Wire code: `invalid_exact_evm_payload_recipient_mismatch`.
55    InvalidExactEvmPayloadRecipientMismatch,
56    /// Specified blockchain network is not supported.
57    /// Wire code: `invalid_network`.
58    InvalidNetwork,
59    /// Payment payload is malformed or contains invalid data.
60    /// Wire code: `invalid_payload`.
61    InvalidPayload,
62    /// Payment requirements object is invalid or malformed.
63    /// Wire code: `invalid_payment_requirements`.
64    InvalidPaymentRequirements,
65    /// Specified payment scheme is not supported.
66    /// Wire code: `invalid_scheme`.
67    InvalidScheme,
68    /// Payment scheme is not supported by the facilitator.
69    /// Wire code: `unsupported_scheme`.
70    UnsupportedScheme,
71    /// Protocol version is not supported.
72    /// Wire code: `invalid_x402_version`.
73    InvalidX402Version,
74    /// Blockchain transaction failed or was rejected.
75    /// Wire code: `invalid_transaction_state`.
76    InvalidTransactionState,
77    /// Unexpected error occurred during payment verification.
78    /// Wire code: `unexpected_verify_error`.
79    UnexpectedVerifyError,
80    /// Unexpected error occurred during payment settlement.
81    /// Wire code: `unexpected_settle_error`.
82    UnexpectedSettleError,
83
84    // Cross-chain reserved codes (SDK consensus).
85    /// Facilitator detected a duplicate settlement attempt and rejected it.
86    /// Wire code: `duplicate_settlement`.
87    DuplicateSettlement,
88    /// EIP-3009 authorization nonce has already been consumed on-chain.
89    /// Wire code: `nonce_already_used`.
90    NonceAlreadyUsed,
91    /// Permit2 allowance is insufficient; the payer must call `approve`
92    /// on the token contract pointing at the Permit2 address first.
93    /// Wire code: `permit2_allowance_required`.
94    /// HTTP mapping: `412 Precondition Failed`.
95    Permit2AllowanceRequired,
96
97    // Upto scheme codes.
98    /// Resource server requested a settlement amount exceeding the buyer's
99    /// signed maximum authorisation. Reserved by
100    /// `schemes/upto/scheme_upto_evm.md` §4.
101    /// Wire code: `invalid_upto_evm_payload_settlement_exceeds_amount`.
102    InvalidUptoEvmPayloadSettlementExceedsAmount,
103    /// `witness.facilitator` doesn't match any of the facilitator's signer
104    /// addresses. Buyer signed a stale or unauthorised facilitator address.
105    /// Wire code: `upto_facilitator_mismatch`.
106    UptoFacilitatorMismatch,
107    /// On-chain proxy reverted with `UnauthorizedFacilitator` (msg.sender
108    /// is not the witness-bound facilitator).
109    /// Wire code: `upto_unauthorized_facilitator`.
110    UptoUnauthorizedFacilitator,
111    /// On-chain proxy reverted with `AmountExceedsPermitted`.
112    /// Wire code: `upto_amount_exceeds_permitted`.
113    UptoAmountExceedsPermitted,
114
115    // SVM exact scheme codes (chain-prefixed).
116    /// SVM `extra.memo` field did not match the memo instruction data.
117    /// Wire code: `invalid_exact_solana_payload_memo_mismatch`.
118    InvalidExactSolanaPayloadMemoMismatch,
119    /// Number of memo instructions attached is invalid (spec requires
120    /// exactly one when `extra.memo` is declared).
121    /// Wire code: `invalid_exact_solana_payload_memo_count`.
122    InvalidExactSolanaPayloadMemoCount,
123
124    // Catch-all preserving the wire code.
125    /// Catch-all preserving any unknown wire code verbatim. Round-trips
126    /// losslessly through serialisation so clients never lose information.
127    Custom(CompactString),
128}
129
130impl ErrorReason {
131    /// Returns the wire-format code (`snake_case` per spec §9).
132    #[must_use]
133    pub fn as_str(&self) -> &str {
134        match self {
135            Self::InsufficientFunds => "insufficient_funds",
136            Self::InvalidExactEvmPayloadAuthorizationValidAfter => {
137                "invalid_exact_evm_payload_authorization_valid_after"
138            }
139            Self::InvalidExactEvmPayloadAuthorizationValidBefore => {
140                "invalid_exact_evm_payload_authorization_valid_before"
141            }
142            Self::InvalidExactEvmPayloadAuthorizationValueMismatch => {
143                "invalid_exact_evm_payload_authorization_value_mismatch"
144            }
145            Self::InvalidExactEvmPayloadSignature => "invalid_exact_evm_payload_signature",
146            Self::InvalidExactEvmPayloadRecipientMismatch => {
147                "invalid_exact_evm_payload_recipient_mismatch"
148            }
149            Self::InvalidNetwork => "invalid_network",
150            Self::InvalidPayload => "invalid_payload",
151            Self::InvalidPaymentRequirements => "invalid_payment_requirements",
152            Self::InvalidScheme => "invalid_scheme",
153            Self::UnsupportedScheme => "unsupported_scheme",
154            Self::InvalidX402Version => "invalid_x402_version",
155            Self::InvalidTransactionState => "invalid_transaction_state",
156            Self::UnexpectedVerifyError => "unexpected_verify_error",
157            Self::UnexpectedSettleError => "unexpected_settle_error",
158            Self::DuplicateSettlement => "duplicate_settlement",
159            Self::NonceAlreadyUsed => "nonce_already_used",
160            Self::Permit2AllowanceRequired => "permit2_allowance_required",
161            Self::InvalidUptoEvmPayloadSettlementExceedsAmount => {
162                "invalid_upto_evm_payload_settlement_exceeds_amount"
163            }
164            Self::UptoFacilitatorMismatch => "upto_facilitator_mismatch",
165            Self::UptoUnauthorizedFacilitator => "upto_unauthorized_facilitator",
166            Self::UptoAmountExceedsPermitted => "upto_amount_exceeds_permitted",
167            Self::InvalidExactSolanaPayloadMemoMismatch => {
168                "invalid_exact_solana_payload_memo_mismatch"
169            }
170            Self::InvalidExactSolanaPayloadMemoCount => "invalid_exact_solana_payload_memo_count",
171            Self::Custom(s) => s.as_str(),
172        }
173    }
174
175    /// Constructs an [`ErrorReason`] from any wire code, mapping known
176    /// strings to their canonical variants and preserving unknown codes
177    /// as [`Self::Custom`].
178    #[must_use]
179    pub fn from_wire(code: &str) -> Self {
180        match code {
181            "insufficient_funds" => Self::InsufficientFunds,
182            "invalid_exact_evm_payload_authorization_valid_after" => {
183                Self::InvalidExactEvmPayloadAuthorizationValidAfter
184            }
185            "invalid_exact_evm_payload_authorization_valid_before" => {
186                Self::InvalidExactEvmPayloadAuthorizationValidBefore
187            }
188            "invalid_exact_evm_payload_authorization_value_mismatch" => {
189                Self::InvalidExactEvmPayloadAuthorizationValueMismatch
190            }
191            "invalid_exact_evm_payload_signature" => Self::InvalidExactEvmPayloadSignature,
192            "invalid_exact_evm_payload_recipient_mismatch" => {
193                Self::InvalidExactEvmPayloadRecipientMismatch
194            }
195            "invalid_network" => Self::InvalidNetwork,
196            "invalid_payload" => Self::InvalidPayload,
197            "invalid_payment_requirements" => Self::InvalidPaymentRequirements,
198            "invalid_scheme" => Self::InvalidScheme,
199            "unsupported_scheme" => Self::UnsupportedScheme,
200            "invalid_x402_version" => Self::InvalidX402Version,
201            "invalid_transaction_state" => Self::InvalidTransactionState,
202            "unexpected_verify_error" => Self::UnexpectedVerifyError,
203            "unexpected_settle_error" => Self::UnexpectedSettleError,
204            "duplicate_settlement" => Self::DuplicateSettlement,
205            "nonce_already_used" => Self::NonceAlreadyUsed,
206            "permit2_allowance_required" => Self::Permit2AllowanceRequired,
207            "invalid_upto_evm_payload_settlement_exceeds_amount" => {
208                Self::InvalidUptoEvmPayloadSettlementExceedsAmount
209            }
210            "upto_facilitator_mismatch" => Self::UptoFacilitatorMismatch,
211            "upto_unauthorized_facilitator" => Self::UptoUnauthorizedFacilitator,
212            "upto_amount_exceeds_permitted" => Self::UptoAmountExceedsPermitted,
213            "invalid_exact_solana_payload_memo_mismatch" => {
214                Self::InvalidExactSolanaPayloadMemoMismatch
215            }
216            "invalid_exact_solana_payload_memo_count" => Self::InvalidExactSolanaPayloadMemoCount,
217            other => Self::Custom(CompactString::from(other)),
218        }
219    }
220}
221
222impl Display for ErrorReason {
223    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
224        f.write_str(self.as_str())
225    }
226}
227
228impl Serialize for ErrorReason {
229    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
230        serializer.serialize_str(self.as_str())
231    }
232}
233
234impl<'de> Deserialize<'de> for ErrorReason {
235    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
236        let s = CompactString::deserialize(deserializer)?;
237        Ok(Self::from_wire(&s))
238    }
239}
240
241impl From<&str> for ErrorReason {
242    fn from(value: &str) -> Self {
243        Self::from_wire(value)
244    }
245}
246
247impl From<CompactString> for ErrorReason {
248    fn from(value: CompactString) -> Self {
249        Self::from_wire(&value)
250    }
251}
252
253impl From<String> for ErrorReason {
254    fn from(value: String) -> Self {
255        Self::from_wire(&value)
256    }
257}
258
259/// A structured payment-problem record returned through wire boundaries.
260///
261/// Not itself serialisable — it is an internal, strongly-typed bridge between
262/// Rust errors and [`crate::wire::VerifyResponse::Invalid`] /
263/// [`crate::wire::SettleResponse::Failure`] on the wire.
264#[derive(Debug, Clone, PartialEq, Eq)]
265pub struct PaymentProblem {
266    reason: ErrorReason,
267    details: String,
268}
269
270impl PaymentProblem {
271    /// Creates a new problem record.
272    #[must_use]
273    pub const fn new(reason: ErrorReason, details: String) -> Self {
274        Self { reason, details }
275    }
276
277    /// Returns the wire-level reason code (clones the inner string for the
278    /// `Custom` variant; cheap for unit variants).
279    #[must_use]
280    pub fn reason(&self) -> ErrorReason {
281        self.reason.clone()
282    }
283
284    /// Returns a borrowed reference to the wire-level reason code.
285    #[must_use]
286    pub const fn reason_ref(&self) -> &ErrorReason {
287        &self.reason
288    }
289
290    /// Returns the human-readable details.
291    #[must_use]
292    pub fn details(&self) -> &str {
293        &self.details
294    }
295}
296
297/// Trait for converting errors into [`PaymentProblem`]s.
298pub trait AsPaymentProblem {
299    /// Produces the canonical wire-level payment problem.
300    fn as_payment_problem(&self) -> PaymentProblem;
301}