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}